Feature spec
IntermediateCategory
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
Everything it takes to ship User Reviews and Ratings — parts, prompts, and real costs.
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.
StarRating component
UIInteractive 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
UITextarea 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
UIInfinite 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
DataA 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
BackendA 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
BackendSELECT 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:
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 06);78create 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);1920create 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);2627create 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);3435-- Postgres trigger to keep aggregate rating current36create or replace function public.update_product_rating()37returns trigger language plpgsql as $$38begin39 update public.products40 set41 average_rating = (42 select round(avg(rating)::numeric, 1)43 from public.reviews44 where product_id = coalesce(new.product_id, old.product_id)45 ),46 review_count = (47 select count(*)48 from public.reviews49 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$$;5556create trigger reviews_update_product_rating57 after insert or update or delete on public.reviews58 for each row execute function public.update_product_rating();5960-- RLS61alter 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;6465create policy "Anyone can read reviews"66 on public.reviews for select67 using (true);6869create policy "Auth users can insert own review"70 on public.reviews for insert71 with check (auth.uid() = user_id);7273create policy "Users can update own review"74 on public.reviews for update75 using (auth.uid() = user_id);7677create policy "Users can delete own review"78 on public.reviews for delete79 using (auth.uid() = user_id);8081create policy "Auth users can vote"82 on public.review_votes for insert83 with check (auth.uid() = user_id);8485create policy "Users can read own votes"86 on public.review_votes for select87 using (auth.uid() = user_id);8889-- Indexes90create 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 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
- 1Prompt v0 with the spec below to generate the ProductReviews component and the API routes
- 2Add Supabase environment variables in the Vars panel: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY
- 3Run the SQL schema and trigger from this page in the Supabase SQL Editor
- 4Test the star selector: confirm it does not render emoji characters by inspecting the DOM
- 5Deploy to Vercel and confirm the review list loads for logged-out visitors by opening the page in an incognito window
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:
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Supabase | Reviews database, RLS, Postgres trigger for aggregate rating, Edge Function for moderation flag handler | Free tier: 500MB DB, 2 projects | Pro $25/mo (8GB DB) |
| Slack Incoming Webhooks | Instant admin notification when a review is flagged for moderation | Free with no limits on incoming webhooks | Free |
| Perspective API (optional) | Automated toxicity scoring before a review is accepted — rejects spam and hate speech | Free up to 1 QPS | Approximately $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
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
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
Disable the submit button on first click to prevent double submissions — restore it only if the Supabase call returns an error
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
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
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
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
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.
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.
Need this feature production-ready?
RapidDev builds user reviews and ratings into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.
