# How to Build a Reviews & Ratings with Lovable

- Tool: How to Build with Lovable
- Difficulty: Beginner
- Compatibility: Lovable (any plan), Supabase Free tier
- Last updated: April 2026

## TL;DR

Build a reviews and ratings system in Lovable where users submit 1-5 star reviews on any item, a database trigger recalculates the average score automatically, and admins moderate submissions through a DataTable. A unique constraint prevents duplicate reviews per user per item. Rate limiting and a profanity check via Edge Function protect the system from abuse.

## Before you start

- Lovable account (Free plan works)
- Supabase project with Auth enabled
- Supabase project URL and anon key ready
- Basic familiarity with Lovable's chat prompt interface

## Step-by-step guide

### 1. Create the schema with triggers and unique constraints

Run this SQL in Supabase. The average_rating column on reviewable_items is maintained by a trigger — every time a review is inserted, updated, or deleted, the trigger recalculates the average for that item automatically.

```
-- Run in Supabase SQL Editor
create table public.reviewable_items (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  description text,
  average_rating numeric(3,2) not null default 0,
  review_count integer not null default 0,
  created_at timestamptz not null default now()
);

create table public.reviews (
  id uuid primary key default gen_random_uuid(),
  item_id uuid not null references public.reviewable_items(id) on delete cascade,
  user_id uuid not null references auth.users(id),
  rating integer not null check (rating between 1 and 5),
  title text,
  body text,
  status text not null default 'pending' check (status in ('pending','approved','rejected')),
  helpful_count integer not null default 0,
  created_at timestamptz not null default now(),
  unique (item_id, user_id)
);

create table public.review_votes (
  id uuid primary key default gen_random_uuid(),
  review_id uuid not null references public.reviews(id) on delete cascade,
  user_id uuid not null references auth.users(id),
  vote text not null check (vote in ('helpful','unhelpful')),
  unique (review_id, user_id)
);

-- Trigger to recalculate average_rating and review_count
create or replace function public.recalculate_item_rating()
returns trigger language plpgsql as $$
declare
  target_item_id uuid;
begin
  target_item_id := coalesce(new.item_id, old.item_id);
  update public.reviewable_items
  set
    average_rating = coalesce((select avg(rating) from public.reviews where item_id = target_item_id and status = 'approved'), 0),
    review_count = (select count(*) from public.reviews where item_id = target_item_id and status = 'approved')
  where id = target_item_id;
  return new;
end;
$$;

create trigger reviews_rating_trigger
after insert or update or delete on public.reviews
for each row execute function public.recalculate_item_rating();

alter table public.reviewable_items enable row level security;
alter table public.reviews enable row level security;
alter table public.review_votes enable row level security;

-- Public can read items and approved reviews
create policy "public_read_items" on public.reviewable_items for select to anon using (true);
create policy "public_read_approved_reviews" on public.reviews for select to anon using (status = 'approved');

-- Authenticated users can submit reviews and votes
create policy "auth_insert_review" on public.reviews for insert to authenticated with check (user_id = auth.uid());
create policy "auth_vote" on public.review_votes for insert to authenticated with check (user_id = auth.uid());
create policy "auth_read_own_review" on public.reviews for select to authenticated using (user_id = auth.uid() or status = 'approved');

-- Admins can update review status
create policy "admin_update_reviews" on public.reviews for update to authenticated using (true);
```

> Pro tip: The trigger only counts approved reviews in the average. This means approving a review in the admin panel automatically updates the item's score without any extra code.

**Expected result:** Three tables appear in Supabase Table Editor. In the Database → Functions section you can see recalculate_item_rating. Submitting and approving a test review should update the item's average_rating automatically.

### 2. Scaffold the project with Lovable

Connect Supabase in Lovable's Cloud tab and use the prompt below to generate the reviews widget, public item page, and admin moderation panel.

```
// Lovable prompt — paste into chat
// Build a reviews and ratings system with Supabase.
// Tables: reviewable_items (name, average_rating, review_count), reviews (rating 1-5, title, body, status, helpful_count), review_votes.
// Pages:
//   /items/[id] — public page: item name, average star display (filled/empty stars), review count,
//     list of approved reviews (Card per review: stars, title, body, date, helpful count, Vote Helpful button),
//     Review submission form at bottom (star selector, title Input, body Textarea).
//     If user already reviewed this item show their existing review instead of the form.
//   /admin/reviews — protected DataTable: reviewer, item, rating stars, title, status Badge, created_at, Actions.
//     Actions DropdownMenu: Approve, Reject.
//     Tabs: All | Pending | Approved | Rejected.
// Use shadcn/ui throughout. Star rating uses 5 Button components with Star icon fill toggling.
```

> Pro tip: After generation, ask Lovable to add a rating distribution breakdown — a small bar showing how many 5-star, 4-star, etc. reviews exist. This improves the trust signal on the item page.

**Expected result:** Lovable generates the item detail page with a star selector, reviews list, and the admin moderation DataTable. Preview shows placeholder stars and an empty reviews list.

### 3. Build the interactive star rating component

The star rating selector shows five stars that fill on hover and lock on click. It uses local hover state and a selected value controlled by React Hook Form.

```
// src/components/StarRating.tsx
import { useState } from 'react'
import { Star } from 'lucide-react'
import { cn } from '@/lib/utils'

type Props = {
  value: number
  onChange: (value: number) => void
  readonly?: boolean
  size?: 'sm' | 'md' | 'lg'
}

const sizes = { sm: 'h-4 w-4', md: 'h-6 w-6', lg: 'h-8 w-8' }

export function StarRating({ value, onChange, readonly = false, size = 'md' }: Props) {
  const [hovered, setHovered] = useState(0)

  return (
    <div className="flex gap-1">
      {[1, 2, 3, 4, 5].map(star => (
        <button
          key={star}
          type="button"
          disabled={readonly}
          onClick={() => !readonly && onChange(star)}
          onMouseEnter={() => !readonly && setHovered(star)}
          onMouseLeave={() => !readonly && setHovered(0)}
          className={cn('focus:outline-none', !readonly && 'cursor-pointer hover:scale-110 transition-transform')}
          aria-label={`${star} star${star !== 1 ? 's' : ''}`}
        >
          <Star
            className={cn(
              sizes[size],
              (hovered || value) >= star ? 'fill-yellow-400 text-yellow-400' : 'text-muted-foreground'
            )}
          />
        </button>
      ))}
    </div>
  )
}
```

> Pro tip: Use the readonly prop to display static star ratings on review cards. Pass the numeric average_rating (e.g., 3.7) by rounding it to the nearest integer for display, and show the exact decimal next to the stars.

**Expected result:** Stars highlight yellow on hover from left to right. Clicking locks the selection. The component works in both interactive and readonly modes.

### 4. Build the review submission form with duplicate prevention

Before showing the form, check if the current user already reviewed this item. If they have, show their existing review. If they submit and the unique constraint fires, show a friendly error instead of a raw Supabase error.

```
// src/components/ReviewForm.tsx
import { useEffect, useState } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { supabase } from '@/lib/supabase'
import { StarRating } from './StarRating'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { toast } from 'sonner'

const schema = z.object({
  rating: z.number().min(1, 'Please select a star rating').max(5),
  title: z.string().max(120).optional(),
  body: z.string().max(1000).optional()
})
type FormValues = z.infer<typeof schema>

type Props = { itemId: string }

export function ReviewForm({ itemId }: Props) {
  const [existing, setExisting] = useState<any>(null)
  const [loading, setLoading] = useState(true)

  const form = useForm<FormValues>({
    resolver: zodResolver(schema),
    defaultValues: { rating: 0 }
  })

  useEffect(() => {
    supabase.auth.getUser().then(({ data: { user } }) => {
      if (!user) { setLoading(false); return }
      supabase.from('reviews').select('*').eq('item_id', itemId).eq('user_id', user.id).maybeSingle()
        .then(({ data }) => { setExisting(data); setLoading(false) })
    })
  }, [itemId])

  async function onSubmit(values: FormValues) {
    const { data: { user } } = await supabase.auth.getUser()
    if (!user) { toast.error('Sign in to leave a review'); return }
    const { error } = await supabase.from('reviews').insert({
      item_id: itemId, user_id: user.id, ...values
    })
    if (error?.code === '23505') {
      toast.error('You already reviewed this item')
      return
    }
    if (error) { toast.error('Failed to submit review'); return }
    toast.success('Review submitted — it will appear after moderation')
    form.reset()
  }

  if (loading) return null

  if (existing) return (
    <Card><CardHeader><CardTitle>Your Review</CardTitle></CardHeader>
      <CardContent className="space-y-2">
        <StarRating value={existing.rating} onChange={() => {}} readonly />
        {existing.title && <p className="font-medium">{existing.title}</p>}
        {existing.body && <p className="text-sm text-muted-foreground">{existing.body}</p>}
        <Badge variant="outline">{existing.status}</Badge>
      </CardContent>
    </Card>
  )

  return (
    <Card><CardHeader><CardTitle>Write a Review</CardTitle></CardHeader>
      <CardContent>
        <Form {...form}>
          <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
            <Controller control={form.control} name="rating" render={({ field }) => (
              <FormItem><FormLabel>Rating</FormLabel>
                <StarRating value={field.value} onChange={field.onChange} size="lg" />
                {form.formState.errors.rating && <p className="text-sm text-destructive">{form.formState.errors.rating.message}</p>}
              </FormItem>
            )} />
            <FormField control={form.control} name="title" render={({ field }) => (
              <FormItem><FormLabel>Title (optional)</FormLabel><FormControl><Input placeholder="Summary" {...field} /></FormControl><FormMessage /></FormItem>
            )} />
            <FormField control={form.control} name="body" render={({ field }) => (
              <FormItem><FormLabel>Review (optional)</FormLabel><FormControl><Textarea placeholder="Share your experience" {...field} /></FormControl><FormMessage /></FormItem>
            )} />
            <Button type="submit" disabled={form.formState.isSubmitting}>Submit Review</Button>
          </form>
        </Form>
      </CardContent>
    </Card>
  )
}
```

**Expected result:** Unauthenticated users see a 'Sign in to leave a review' toast. Authenticated users see the form. After submitting, a pending badge shows on their review. Submitting twice shows the duplicate error message.

### 5. Build the admin moderation DataTable with Tabs

The admin moderation panel shows all reviews filtered by status. Approving a review triggers the database trigger which recalculates the item's average_rating automatically.

```
// src/pages/AdminReviews.tsx (key section)
import { useEffect, useState } from 'react'
import { supabase } from '@/lib/supabase'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { DataTable } from '@/components/ui/data-table'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { MoreHorizontal } from 'lucide-react'
import { toast } from 'sonner'
import type { ColumnDef } from '@tanstack/react-table'

type Review = { id: string; rating: number; title: string; status: string; created_at: string; reviewable_items: { name: string } }

const statusVariants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
  pending: 'secondary', approved: 'default', rejected: 'destructive'
}

export function AdminReviews() {
  const [reviews, setReviews] = useState<Review[]>([])
  const [tab, setTab] = useState('all')

  useEffect(() => {
    supabase.from('reviews').select('*, reviewable_items(name)').order('created_at', { ascending: false })
      .then(({ data }) => setReviews(data ?? []))
  }, [])

  async function updateStatus(id: string, status: string) {
    const { error } = await supabase.from('reviews').update({ status }).eq('id', id)
    if (error) { toast.error('Update failed'); return }
    setReviews(prev => prev.map(r => r.id === id ? { ...r, status } : r))
    toast.success(`Review ${status}`)
  }

  const filtered = tab === 'all' ? reviews : reviews.filter(r => r.status === tab)

  const columns: ColumnDef<Review>[] = [
    { accessorKey: 'reviewable_items.name', header: 'Item' },
    { accessorKey: 'rating', header: 'Rating', cell: ({ row }) => `${'★'.repeat(row.original.rating)}${'☆'.repeat(5 - row.original.rating)}` },
    { accessorKey: 'title', header: 'Title' },
    { accessorKey: 'status', header: 'Status', cell: ({ row }) => <Badge variant={statusVariants[row.original.status]}>{row.original.status}</Badge> },
    { id: 'actions', cell: ({ row }) => (
      <DropdownMenu>
        <DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
        <DropdownMenuContent>
          <DropdownMenuItem onClick={() => updateStatus(row.original.id, 'approved')}>Approve</DropdownMenuItem>
          <DropdownMenuItem onClick={() => updateStatus(row.original.id, 'rejected')}>Reject</DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
    )}
  ]

  return (
    <div className="p-6">
      <Tabs value={tab} onValueChange={setTab}>
        <TabsList><TabsTrigger value="all">All</TabsTrigger><TabsTrigger value="pending">Pending</TabsTrigger><TabsTrigger value="approved">Approved</TabsTrigger><TabsTrigger value="rejected">Rejected</TabsTrigger></TabsList>
        <TabsContent value={tab}><DataTable columns={columns} data={filtered} /></TabsContent>
      </Tabs>
    </div>
  )
}
```

> Pro tip: When you approve a review, the database trigger fires automatically and recalculates reviewable_items.average_rating. You can verify this by checking the item's row in Supabase Table Editor after approving.

**Expected result:** The admin table shows all reviews across all statuses. Switching tabs filters correctly. Clicking Approve updates the Badge instantly and triggers the database average recalculation.

## Complete code example

File: `src/components/StarRating.tsx`

```typescript
import { useState } from 'react'
import { Star } from 'lucide-react'
import { cn } from '@/lib/utils'

type Props = {
  value: number
  onChange: (value: number) => void
  readonly?: boolean
  size?: 'sm' | 'md' | 'lg'
  showLabel?: boolean
}

const sizeClasses = {
  sm: 'h-4 w-4',
  md: 'h-6 w-6',
  lg: 'h-8 w-8'
}

const labels: Record<number, string> = {
  1: 'Terrible',
  2: 'Poor',
  3: 'Average',
  4: 'Good',
  5: 'Excellent'
}

export function StarRating({
  value,
  onChange,
  readonly = false,
  size = 'md',
  showLabel = false
}: Props) {
  const [hovered, setHovered] = useState(0)
  const activeValue = hovered || value

  return (
    <div className="flex items-center gap-2">
      <div
        className="flex gap-0.5"
        role={readonly ? 'img' : 'radiogroup'}
        aria-label={`Rating: ${value} out of 5 stars`}
      >
        {[1, 2, 3, 4, 5].map(star => (
          <button
            key={star}
            type="button"
            disabled={readonly}
            onClick={() => !readonly && onChange(star)}
            onMouseEnter={() => !readonly && setHovered(star)}
            onMouseLeave={() => !readonly && setHovered(0)}
            className={cn(
              'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm',
              !readonly && 'cursor-pointer hover:scale-110 transition-transform duration-100',
              readonly && 'cursor-default'
            )}
            aria-label={`${star} star${star !== 1 ? 's' : ''}`}
            aria-pressed={!readonly ? value === star : undefined}
          >
            <Star
              className={cn(
                sizeClasses[size],
                'transition-colors duration-100',
                activeValue >= star
                  ? 'fill-yellow-400 text-yellow-400'
                  : 'fill-none text-muted-foreground'
              )}
            />
          </button>
        ))}
      </div>
      {showLabel && hovered > 0 && (
        <span className="text-sm text-muted-foreground">{labels[hovered]}</span>
      )}
      {showLabel && hovered === 0 && value > 0 && (
        <span className="text-sm text-muted-foreground">{labels[value]}</span>
      )}
    </div>
  )
}
```

## Common mistakes

- **Not filtering reviews by status = 'approved' on the public page** — Pending and rejected reviews are readable by anon if your RLS policy allows it. Showing unmoderated content publicly exposes spam and inappropriate text to all visitors. Fix: The RLS policy should be: CREATE POLICY "public_read_approved_reviews" ON public.reviews FOR SELECT TO anon USING (status = 'approved'). Double-check this in Supabase → Table Editor → Policies.
- **Using .upsert() instead of .insert() for reviews** — Upsert bypasses the unique constraint and silently overwrites the existing review instead of returning error code 23505, removing the duplicate detection. Fix: Always use .insert() and handle the 23505 error code explicitly to show the 'already reviewed' message.
- **Calculating average_rating in the React component** — Client-side averages are stale, require fetching all reviews per page load, and reset on refresh. They diverge from the database value if moderating reviews removes some ratings. Fix: Use the average_rating column on reviewable_items which is kept current by the database trigger on every review status change.
- **Not resetting the star rating field after form submission** — Calling form.reset() resets text inputs but the star component's local state remains on the last selected value, visually showing a stale rating. Fix: Control the star value via React Hook Form's Controller and include rating in the defaultValues object. Calling form.reset() with defaultValues: { rating: 0 } resets the stars too.
- **Forgetting to update helpful_count when a vote is cast** — If you only insert a row into review_votes without updating reviews.helpful_count, the displayed count never changes. Fix: Use a database trigger on review_votes INSERT/DELETE that increments or decrements reviews.helpful_count, similar to the average rating trigger.

## Best practices

- Put all rating recalculation logic in a database trigger so it fires regardless of whether reviews are updated via the app, the Supabase dashboard, or a script.
- Show the review count alongside the star average — '4.2 (127 reviews)' is far more trustworthy than '4.2 (3 reviews)'.
- Sort approved reviews by helpful_count descending by default so the most useful reviews appear first, with a secondary sort by created_at for new reviews.
- Require authentication for all review submissions — anonymous reviews increase spam and reduce trust in the rating system.
- Surface the pending review count as a badge on the admin navigation link so moderators know when new reviews are waiting.
- Implement a profanity filter in a Supabase Edge Function called before insert rather than relying on post-moderation, to reduce admin workload.
- Never auto-approve reviews — even if you trust your users, manual approval catches edge cases like formatting abuse and duplicate content.
- Add a Report button on each review so users can flag inappropriate content that passed moderation, linking to a separate reports table.

## Frequently asked questions

### How does the average rating update automatically when I approve a review?

A PostgreSQL trigger named reviews_rating_trigger fires AFTER INSERT OR UPDATE OR DELETE on the reviews table. It runs a function that recalculates AVG(rating) from all approved reviews and writes the result back to the reviewable_items.average_rating column. Approving a review changes its status column, which fires the trigger.

### Can a user edit their review after submitting it?

Not by default. Add an UPDATE RLS policy: CREATE POLICY "auth_update_own_review" ON public.reviews FOR UPDATE TO authenticated USING (user_id = auth.uid()), then add an Edit button to the existing review Card that opens the form pre-populated with their values.

### How do I prevent the same user from voting on the same review twice?

The review_votes table has a UNIQUE constraint on (review_id, user_id). Attempting a second vote returns error code 23505. Handle it in your vote handler and show a message like 'You already voted on this review'.

### Why does the star rating show 0 after form reset?

Make sure the StarRating component is controlled via React Hook Form's Controller and that form.reset() includes rating: 0 in the default values object. If the star value is only stored in local component state, reset() cannot reach it.

### How do I show reviews without auth for anonymous visitors?

The RLS policy 'public_read_approved_reviews' allows anonymous reads where status = 'approved'. The Supabase client initialized with the anon key fetches this data without any authentication. Do not use the service role key on the client side.

### Can I embed the reviews widget on an external website?

Yes — publish the item page in Lovable and embed it as an iframe. Alternatively, export the code from Dev Mode, self-host the React component, and configure it to connect to your Supabase project using the public anon key.

### Can RapidDev help me integrate this reviews system into an existing Lovable app?

Yes. RapidDev can audit your existing Lovable project, add the reviews schema with triggers to your Supabase instance, and integrate the UI components into your existing pages. Visit rapiddev.io to get started.

### How do I display structured data for Google star snippets?

On the server-rendered item page, inject a JSON-LD script tag with type 'AggregateRating' using the average_rating and review_count from your Supabase query. In a Lovable/Vite app, add this dynamically using react-helmet or by updating the document head in a useEffect.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/reviews-ratings
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/reviews-ratings
