Skip to main content
RapidDev - Software Development Agency
App Featuresanalytics-admin19 min read

How to Add User Reviews and Ratings to Your App (Copy-Paste Prompts Included)

A reviews and ratings feature needs five pieces: an interactive star selector, a review submission form with one-review-per-user enforcement, a paginated review list with avatars, a Postgres trigger that keeps the aggregate rating updated, and RLS policies that let anyone read but only the author can edit. With Lovable or v0 you can ship this in 3-5 hours for $0/month on Supabase's free tier.

4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members

Feature spec

Intermediate

Category

analytics-admin

Build with AI

3-6 hours with Lovable or v0

Custom build

1-2 weeks custom dev

Running cost

$0/mo up to 1,000 users; $25/mo at 10,000 users

Works on

Web

Everything it takes to ship User Reviews and Ratings — parts, prompts, and real costs.

TL;DR

A reviews and ratings feature needs five pieces: an interactive star selector, a review submission form with one-review-per-user enforcement, a paginated review list with avatars, a Postgres trigger that keeps the aggregate rating updated, and RLS policies that let anyone read but only the author can edit. With Lovable or v0 you can ship this in 3-5 hours for $0/month on Supabase's free tier.

What a Reviews and Ratings Feature Actually Is

A reviews and ratings system lets users leave a 1-5 star score and a written comment on a product, course, or service — and lets everyone else read those opinions before deciding whether to buy or enrol. The key product decisions are: how many reviews per user per item (always one), whether reviews appear immediately or after moderation, and how the aggregate score stays accurate as reviews are added or edited. The star selector and the review list are solved UI problems; the aggregate calculation and the one-per-user enforcement are where first builds reliably break.

What users consider table stakes in 2026

  • 1-5 star selector with clear visual fill state — clicking a star sets the rating, hovering previews it
  • Aggregate rating displayed as filled stars + numeric average rounded to one decimal + total review count (e.g. '4.3 ★ · 128 reviews')
  • Chronologically sorted review list with author name, avatar, and relative timestamp ('3 days ago')
  • Edit and delete controls visible only to the review's author — not to other users
  • Helpful / not-helpful voting buttons on each review, with the count displayed
  • Moderation flag option so readers can report inappropriate content

Anatomy of the Feature

Six components. Lovable and v0 handle the UI and RLS well on a first prompt. The Postgres trigger for aggregate recalculation and the moderation flow reliably need a second pass.

Layers:UIDataBackend

StarRating component

UI

Interactive star selector built with SVG icons (Heroicons or custom) — renders filled, half-filled, and empty states. Keyboard-accessible via arrow keys and Enter for WCAG compliance. Can use the react-rating package or a 30-line custom SVG implementation — the package adds 6KB but saves an hour of accessibility wiring.

Note: Half-star precision requires a custom implementation; react-rating supports it but v0 defaults to full-star only unless explicitly specified in the prompt.

Review submission form

UI

Textarea for the review body + star input + optional title field. Validated with react-hook-form + zod: body minimum 10 characters, rating must be 1-5. Submitted via Supabase upsert with onConflict on (product_id, user_id) to enforce one review per user per item — upsert instead of insert means editing the existing review rather than creating a second one.

Note: Always use upsert here, not insert. An insert will throw a unique constraint error if the user tries to update their review; upsert silently updates the existing row.

Reviews list with pagination

UI

Infinite scroll or page-based list using Supabase .range(from, to) pagination. Sorted by helpful_votes DESC then created_at DESC by default, with UI controls to switch to Newest or Highest Rating. Shows the author's avatar from Supabase Auth user_metadata.avatar_url with a fallback initials placeholder.

Note: Fetch avatars from user_metadata.avatar_url — do not store a separate avatar column; Supabase Auth already holds it from OAuth providers.

Aggregate rating calculation

Data

A Postgres trigger fires AFTER INSERT, UPDATE, or DELETE on the reviews table and recalculates AVG(rating) and COUNT(*) for that product, writing the result back to products.average_rating and products.review_count. This keeps the aggregate accurate in real time without a cron job or a slow GROUP BY on every page load.

Note: The trigger SQL is often generated as a code block in the AI's response but not automatically executed. Paste it into the Supabase SQL Editor manually to activate it.

Moderation flag endpoint

Backend

A Supabase Edge Function that inserts a row into review_reports (review_id, reporter_id, reason) and posts a Slack message via Slack Incoming Webhooks (free) so the admin sees the report immediately. The flagged review stays visible until an admin deletes it using the service_role key.

Note: For automated toxicity filtering, Perspective API from Google is free up to 1 QPS; beyond that it costs approximately $1/1,000 requests. Wire it inside the Edge Function before inserting into reviews.

RLS policy layer

Backend

SELECT is open to all users (including anonymous visitors) so the review list loads publicly. INSERT, UPDATE, and DELETE are restricted to auth.uid() = user_id. Moderation deletes (admin removing a flagged review) use the service_role key inside the Edge Function — never expose the service_role key to the client.

Note: The most common RLS mistake: the AI generates SELECT with auth.uid() = user_id instead of a public read policy. This makes the review list invisible to logged-out visitors.

The data model

Four tables: products (with the aggregate columns), reviews, review_votes, and review_reports. The Postgres trigger keeps products.average_rating current automatically. Paste this into the Supabase SQL Editor and run it before prompting your AI tool:

schema.sql
1create table public.products (
2 id uuid primary key default gen_random_uuid(),
3 title text not null,
4 average_rating numeric(3,1) default 0,
5 review_count int default 0
6);
7
8create table public.reviews (
9 id uuid primary key default gen_random_uuid(),
10 product_id uuid references public.products(id) on delete cascade not null,
11 user_id uuid references auth.users(id) on delete cascade not null,
12 rating smallint not null check (rating between 1 and 5),
13 title text,
14 body text not null,
15 created_at timestamptz not null default now(),
16 updated_at timestamptz not null default now(),
17 unique (product_id, user_id)
18);
19
20create table public.review_votes (
21 review_id uuid references public.reviews(id) on delete cascade not null,
22 user_id uuid references auth.users(id) on delete cascade not null,
23 is_helpful boolean not null,
24 primary key (review_id, user_id)
25);
26
27create table public.review_reports (
28 id uuid primary key default gen_random_uuid(),
29 review_id uuid references public.reviews(id) on delete cascade not null,
30 reporter_id uuid references auth.users(id) on delete cascade not null,
31 reason text not null,
32 created_at timestamptz not null default now()
33);
34
35-- Postgres trigger to keep aggregate rating current
36create or replace function public.update_product_rating()
37returns trigger language plpgsql as $$
38begin
39 update public.products
40 set
41 average_rating = (
42 select round(avg(rating)::numeric, 1)
43 from public.reviews
44 where product_id = coalesce(new.product_id, old.product_id)
45 ),
46 review_count = (
47 select count(*)
48 from public.reviews
49 where product_id = coalesce(new.product_id, old.product_id)
50 )
51 where id = coalesce(new.product_id, old.product_id);
52 return coalesce(new, old);
53end;
54$$;
55
56create trigger reviews_update_product_rating
57 after insert or update or delete on public.reviews
58 for each row execute function public.update_product_rating();
59
60-- RLS
61alter table public.reviews enable row level security;
62alter table public.review_votes enable row level security;
63alter table public.review_reports enable row level security;
64
65create policy "Anyone can read reviews"
66 on public.reviews for select
67 using (true);
68
69create policy "Auth users can insert own review"
70 on public.reviews for insert
71 with check (auth.uid() = user_id);
72
73create policy "Users can update own review"
74 on public.reviews for update
75 using (auth.uid() = user_id);
76
77create policy "Users can delete own review"
78 on public.reviews for delete
79 using (auth.uid() = user_id);
80
81create policy "Auth users can vote"
82 on public.review_votes for insert
83 with check (auth.uid() = user_id);
84
85create policy "Users can read own votes"
86 on public.review_votes for select
87 using (auth.uid() = user_id);
88
89-- Indexes
90create index reviews_product_idx on public.reviews (product_id, created_at desc);
91create index reviews_helpful_idx on public.reviews (product_id, (select count(*) from public.review_votes v where v.review_id = id and v.is_helpful) desc);

Heads up: The trigger automatically fires on INSERT, UPDATE, and DELETE — you never need to recalculate the average manually. Test it by inserting a review in the Table Editor and checking that products.average_rating updates within a second.

Build it — pick your path

Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.

Best UI, Next.js ecosystemFit for this feature:

Best UI quality: v0 produces a polished Next.js review component with shadcn/ui Card and a custom star selector — ideal for embedding in an existing product page within a Next.js app.

Step by step

  1. 1Prompt v0 with the spec below to generate the ProductReviews component and the API routes
  2. 2Add Supabase environment variables in the Vars panel: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY
  3. 3Run the SQL schema and trigger from this page in the Supabase SQL Editor
  4. 4Test the star selector: confirm it does not render emoji characters by inspecting the DOM
  5. 5Deploy to Vercel and confirm the review list loads for logged-out visitors by opening the page in an incognito window
Paste into v0
Build a Next.js reviews and ratings feature. Use Supabase with tables: products (id uuid pk, title text, average_rating numeric(3,1), review_count int), reviews (id uuid pk, product_id uuid fk, user_id uuid fk auth.users, rating smallint check 1-5, title text, body text not null, created_at timestamptz, updated_at timestamptz, unique(product_id, user_id)), review_votes (review_id, user_id, is_helpful boolean, pk review_id+user_id). RLS: reviews SELECT using true (public read); INSERT with check auth.uid()=user_id; UPDATE/DELETE using auth.uid()=user_id. Component ProductReviews on /products/[id]: fetch product and reviews server-side via Supabase Server Client. Show aggregate as '4.3 ★ · 128 reviews' using filled SVG star icons — do NOT use emoji star characters. Star selector: interactive SVG stars where clicking a star sets the rating value 1-5; hovering previews; keyboard accessible (arrow keys + Enter). Review form: react-hook-form + zod (body min 10 chars, rating 1-5 required). Submit handler: upsert to reviews with onConflict 'product_id,user_id'. Edit handler: use .update({ rating, body, updated_at: new Date() }).eq('id', reviewId).eq('user_id', userId) — never upsert on edit. Review list: shadcn/ui Card per review showing avatar (user_metadata.avatar_url or initials fallback), star display (read-only), title, body, relative timestamp. Helpful/not-helpful buttons upsert into review_votes. Show edit/delete only when review.user_id === session.user.id. Sort: helpful_votes DESC then created_at DESC by default; add sort control for Newest and Highest Rating. Paginate with .range() and a Load More button.

Where this path bites

  • v0 does not auto-provision Supabase — environment variables must be set manually in the Vars panel and the SQL schema must be run in Supabase manually
  • Supabase Realtime subscription for live aggregate updates requires a custom useEffect that v0 does not generate automatically
  • The admin moderation flow (viewing flags, deleting reviews with service_role key) must be added as a separate prompt or built in Lovable as a companion admin panel

Third-party services you'll need

The core review and rating feature runs entirely on Supabase with no paid services. Moderation and toxicity filtering are optional additions:

ServiceWhat it doesFree tierPaid from
SupabaseReviews database, RLS, Postgres trigger for aggregate rating, Edge Function for moderation flag handlerFree tier: 500MB DB, 2 projectsPro $25/mo (8GB DB)
Slack Incoming WebhooksInstant admin notification when a review is flagged for moderationFree with no limits on incoming webhooksFree
Perspective API (optional)Automated toxicity scoring before a review is accepted — rejects spam and hate speechFree up to 1 QPSApproximately $1/1,000 requests beyond free tier (approx)

Swipe the table sideways to see pricing.

What it costs to run

Drag through the tiers to see how your monthly bill scales with users — no surprises later.

Estimated monthly running cost

$0/mo

Supabase free tier easily handles read/write volume for 100 users. Slack webhooks are free. No product lookup or paid services needed.

Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.

What breaks when AI tools build this

The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.

Duplicate reviews submitted on double-click

Symptom: The review form's submit handler fires twice before the async Supabase call resolves when a user double-clicks the submit button. Without a UNIQUE constraint, two rows are inserted for the same user and product — violating the one-review rule and confusing the aggregate calculation.

Fix: Add UNIQUE (product_id, user_id) to the reviews table (included in the SQL above) and use upsert with onConflict: 'product_id,user_id'. Disable the submit button immediately on first click by setting a loading state, and re-enable it only if the upsert fails.

Average rating not updating after a new review

Symptom: The Postgres trigger that updates products.average_rating is generated by the AI as a SQL code block in the chat response but is never actually run — the AI cannot execute SQL on your behalf. So the trigger function exists in the chat transcript but not in the database, and products.average_rating stays at 0 forever.

Fix: Open Supabase Dashboard → SQL Editor, paste the trigger definition from this page, and run it. Confirm by inserting a test review in the Table Editor and checking that products.average_rating updates within 1 second.

Stars render as text emoji '★★★☆☆' instead of interactive SVGs

Symptom: When the prompt does not specify the rendering method, v0 and Lovable sometimes generate plain text emoji stars. These cannot be clicked to set a rating and look inconsistent across operating systems — iOS renders ★ as a yellow emoji, Windows as a thin outline.

Fix: Add this to your prompt: 'Use an interactive SVG star rating component where clicking a star sets the rating value 1-5. Do not use emoji characters. Render each star as an SVG path with filled/empty states controlled by a React state variable.'

Review list is empty for logged-out visitors

Symptom: The AI adds a SELECT policy with auth.uid() = user_id rather than a public read policy. Logged-out visitors see an empty review list, making the feature invisible to the majority of potential buyers who have not created an account yet.

Fix: Open Supabase Dashboard → Authentication → Policies → reviews table. Change the SELECT policy condition to: using (true) — no auth check, public reads allowed. Keep INSERT, UPDATE, and DELETE restricted to auth.uid() = user_id.

Edited review loses its original created_at timestamp

Symptom: When the edit mutation uses upsert, it resets created_at to the current timestamp because upsert performs an INSERT if the conflict resolution path is not configured to preserve the original value. The review then sorts as if it was just posted, breaking the chronological feed.

Fix: Use .update() (not upsert) for edits: supabase.from('reviews').update({ rating, body, updated_at: new Date().toISOString() }).eq('id', reviewId).eq('user_id', userId). The .eq('user_id', userId) guard means even if someone forges a reviewId, they can only update reviews they own.

Best practices

1

Always use upsert with onConflict: 'product_id,user_id' for the initial review submission — it handles both first submission and re-editing without requiring separate insert and update code paths

2

Disable the submit button on first click to prevent double submissions — restore it only if the Supabase call returns an error

3

Store the aggregate rating in the products table via a Postgres trigger rather than calculating it with GROUP BY on every page load — at 10,000 users the GROUP BY query can take 200-400ms

4

Make SELECT public (using true) by default — most review systems are intended for anonymous visitors deciding whether to buy, not just for logged-in users

5

Show a rating breakdown histogram (5★ 60%, 4★ 25%, 3★ 10%, 2★ 3%, 1★ 2%) — it gives users more signal than the average number alone and is a 30-minute add-on once the data is in the database

6

Validate review body minimum length (10 characters) client-side with zod AND enforce it in the Supabase check constraint — AI-generated forms sometimes omit the database-level constraint

7

Cache the aggregate rating (average_rating, review_count) in the products table rather than recalculating on every request — the Postgres trigger keeps it current at zero cost

When You Need Custom Development

Lovable and v0 handle star ratings and written reviews for standard e-commerce and course platforms cleanly. You outgrow them when trust and safety or ML requirements appear:

  • Review content must pass automated toxicity or spam scoring before it appears publicly — Perspective API integration inside a Supabase Edge Function works, but building a review queue, admin tooling, and appeals flow is a multi-week project
  • Reviews feed a recommendation engine that weights recency, helpfulness, and verified purchase status — the data model and the ML pipeline both require custom development
  • Multi-tenant setup where each merchant controls their own moderation rules, blocked words, and auto-approve thresholds — requires per-tenant RLS policies and a configuration management layer
  • Legal or compliance requirements to retain deleted reviews in an immutable audit log — append-only event sourcing that AI tools do not produce

RapidDev builds these features for production

Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.

Get a fixed-price quote

Frequently asked questions

Can I show reviews from one product on multiple pages without duplicating data?

Yes — store reviews once in the reviews table keyed by product_id and query them wherever the product appears. Use Supabase Server Components to fetch and render the same review list on a product detail page, a category comparison page, and a homepage featured section — all from the same database rows.

How do I prevent fake or duplicate reviews?

Add a UNIQUE constraint on (product_id, user_id) in the reviews table — included in the SQL schema above. This makes it impossible for the same user to submit two reviews for the same product at the database level. For fake account fraud, require email verification before reviewing and rate-limit new accounts.

Can users attach photos to their reviews?

Yes — add an attachment_urls TEXT[] column to reviews and upload images to a Supabase Storage bucket (e.g. 'review-photos'). Add a Supabase Storage INSERT policy: auth.uid()::text = (storage.foldername(name))[1] to scope uploads to the user's own folder. Display uploaded images as a horizontal scroll row below the review body.

How do I sort reviews by most helpful by default?

Count helpful votes with a correlated subquery or a materialized column, then order by that count: .order('helpful_votes', { ascending: false }).order('created_at', { ascending: false }). The second order clause breaks ties by newest first. Add a sort control so users can switch to Newest or Highest Rating.

Will my aggregate rating update instantly after a new review?

Yes — the Postgres trigger fires AFTER INSERT, UPDATE, or DELETE on the reviews table and writes the new average and count back to products immediately. The next page load or Supabase query will return the updated values. There is no delay or cron job required.

Can I moderate reviews before they go live?

Add a status TEXT column to reviews with values 'pending', 'approved', 'rejected' and default 'pending'. Change the public SELECT policy to: using (status = 'approved'). Build an admin route that lists pending reviews and lets an admin set status to 'approved' or 'rejected' using the Supabase service_role key. New reviews are invisible to the public until approved.

How do I display a rating breakdown (5★ 60%, 4★ 25%...)?

Run a Supabase query grouped by rating: .select('rating, count').from('reviews').eq('product_id', productId).groupBy('rating'). Divide each count by the total review_count from the products table to get percentages. Render as a bar chart with five rows, one per star level — a 20-line React component.

How do I export reviews to a CSV for analysis?

Fetch all reviews for your product with the Supabase service_role client (bypassing RLS) and pipe the result array through papaparse.unparse(data) on the client side. Add a Download CSV button to the admin route that triggers this client-side export — no server-side file generation needed.

RapidDev

Need this feature production-ready?

RapidDev builds user reviews and ratings into real apps — auth, database, payments — at $13K–$25K.

Book a free consultation

30-min call. No commitment.

Want this built for you?

We ship production apps at a fixed price — $13K–$25K, 6–10 weeks, source code yours. You've seen what it takes; we do it every week.

Get a fixed-price quote

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.