Build a star rating and review system in Replit in 30-60 minutes. Use Replit Agent to generate an Express + PostgreSQL app with Drizzle ORM that handles item ratings, written reviews, helpful votes, and automatic aggregate score updates via a PostgreSQL trigger. Deploy on Autoscale for read-heavy traffic.
What you're building
Review and rating systems are one of the most-requested features for product, service, and directory apps. A star rating with written feedback builds trust with new visitors and gives owners actionable data. The challenge is not just storing ratings — it is keeping aggregate scores accurate without slowing down every product page query.
Replit Agent generates the full Express backend from a single prompt. The schema uses a PostgreSQL trigger to recalculate average_rating and review_count on the items table every time a review is inserted, updated, or deleted. This denormalization means your product listing endpoint can return a pre-computed average instantly without running AVG() on every request.
The architecture uses three tables: items (the things being reviewed), reviews (one per user per item), and review_votes (helpful/not helpful votes on individual reviews). Express routes handle CRUD operations and Replit Auth identifies the review author. Deploy on Autoscale — review pages are read-heavy and scale well with Replit's serverless model.
Final result
A fully functional review and rating API with item listings, star ratings, written reviews with pros/cons, helpful votes, rating distribution data, and PostgreSQL-maintained aggregate scores — deployed on Replit Autoscale.
Tech stack
Prerequisites
- A Replit account (free tier is sufficient for this build)
- Basic understanding of what a REST API does (no coding experience needed)
- A list of item types you want to make reviewable (products, services, businesses, or locations)
Build steps
Scaffold the project with Replit Agent
Create a new Repl and use the Agent prompt below to generate the full reviews and ratings system. The prompt defines all three tables and the routes Agent needs to generate.
1// Type this into Replit Agent:2// Build a reviews and ratings system with Express and PostgreSQL using Drizzle ORM.3// Tables:4// - items: id serial pk, name text not null, type text (enum product/service/business/location),5// description text, image_url text, average_rating numeric default 0,6// review_count integer default 0, created_at timestamp default now()7// - reviews: id serial, item_id integer FK items not null, user_id text not null,8// rating integer not null (1-5), title text, body text, pros text, cons text,9// is_verified boolean default false, helpful_count integer default 0,10// created_at timestamp default now()11// UNIQUE constraint on (item_id, user_id)12// - review_votes: id serial, review_id integer FK reviews not null,13// user_id text not null, is_helpful boolean not null,14// created_at timestamp default now()15// UNIQUE constraint on (review_id, user_id)16// Routes:17// GET /api/items — list with average_rating and review_count18// GET /api/items/:id — detail19// GET /api/items/:id/reviews — paginated, sort by recent/helpful/rating_high/rating_low20// POST /api/items/:id/reviews — create, one per user per item21// PUT /api/reviews/:id — edit own review22// DELETE /api/reviews/:id — delete own review23// POST /api/reviews/:id/vote — helpful toggle24// GET /api/items/:id/rating-distribution — count per star level25// Use Replit Auth. Bind server to 0.0.0.0.Pro tip: Ask Agent to also create a seed script that inserts 5-10 sample items and 20+ reviews so you can see the aggregate scores and distribution charts working right away without entering data manually.
Expected result: A running Express app on port 5000. The console shows the server started. The Drizzle schema file exists with all three tables defined.
Add the PostgreSQL trigger for automatic aggregate updates
A trigger on the reviews table recalculates average_rating and review_count on items whenever a review is inserted, updated, or deleted. This keeps aggregates accurate without application-level logic or race conditions.
1// Run this after Drizzle migrations in your server startup file.2// It installs the trigger function and attaches it to the reviews table.34const { sql } = require('drizzle-orm');5const { db } = require('./db');67const triggerSQL = `8 CREATE OR REPLACE FUNCTION update_item_rating_aggregates()9 RETURNS TRIGGER AS $$10 DECLARE11 target_item_id INTEGER;12 BEGIN13 IF TG_OP = 'DELETE' THEN14 target_item_id := OLD.item_id;15 ELSE16 target_item_id := NEW.item_id;17 END IF;1819 UPDATE items20 SET21 average_rating = COALESCE(22 (SELECT ROUND(AVG(rating)::numeric, 2)23 FROM reviews WHERE item_id = target_item_id), 0),24 review_count = (25 SELECT COUNT(*) FROM reviews WHERE item_id = target_item_id)26 WHERE id = target_item_id;2728 RETURN NULL;29 END;30 $$ LANGUAGE plpgsql;3132 DROP TRIGGER IF EXISTS reviews_aggregate_trigger ON reviews;33 CREATE TRIGGER reviews_aggregate_trigger34 AFTER INSERT OR UPDATE OR DELETE ON reviews35 FOR EACH ROW36 EXECUTE FUNCTION update_item_rating_aggregates();37`;3839async function setupTrigger() {40 await db.execute(sql.raw(triggerSQL));41 console.log('Rating aggregate trigger installed');42}4344setupTrigger().catch(console.error);4546module.exports = { setupTrigger };Pro tip: The trigger uses COALESCE to handle the case where all reviews for an item are deleted — it resets average_rating to 0 instead of NULL, which prevents display bugs on the frontend.
Expected result: After adding the trigger, posting a review immediately updates the item's average_rating and review_count without any extra query in the route handler.
Build the review creation route with duplicate prevention
The review creation route relies on the unique constraint for enforcement. It catches the PostgreSQL error code 23505 and returns a user-friendly 409 response instead of a generic 500 error.
1const express = require('express');2const { reviews } = require('../../shared/schema');3const { eq } = require('drizzle-orm');4const { db } = require('../db');56const router = express.Router();78// POST /api/items/:id/reviews9router.post('/items/:id/reviews', async (req, res) => {10 const userId = req.user?.id;11 if (!userId) return res.status(401).json({ error: 'Login required to submit a review' });1213 const itemId = parseInt(req.params.id);14 const { rating, title, body, pros, cons } = req.body;1516 if (!rating || rating < 1 || rating > 5) {17 return res.status(400).json({ error: 'Rating must be between 1 and 5' });18 }1920 try {21 const [review] = await db.insert(reviews).values({22 itemId,23 userId,24 rating: parseInt(rating),25 title: title || null,26 body: body || null,27 pros: pros || null,28 cons: cons || null,29 }).returning();3031 // PostgreSQL trigger automatically updates items.average_rating32 res.status(201).json(review);33 } catch (err) {34 if (err.code === '23505') {35 return res.status(409).json({ error: 'You have already reviewed this item' });36 }37 res.status(500).json({ error: err.message });38 }39});4041module.exports = router;Expected result: POST /api/items/1/reviews creates a review and the item's average_rating updates immediately. A second POST by the same user returns 409 with 'You have already reviewed this item'.
Build the helpful vote toggle and rating distribution routes
The vote route uses the review_votes table to prevent duplicate voting. The distribution route uses generate_series to always return all 5 star levels, even if some have zero reviews.
1const { reviewVotes, reviews } = require('../../shared/schema');2const { eq, and, sql, desc } = require('drizzle-orm');34// POST /api/reviews/:id/vote5router.post('/reviews/:id/vote', async (req, res) => {6 const userId = req.user?.id;7 if (!userId) return res.status(401).json({ error: 'Login required to vote' });89 const reviewId = parseInt(req.params.id);10 const { isHelpful } = req.body;1112 const [existing] = await db.select().from(reviewVotes)13 .where(and(eq(reviewVotes.reviewId, reviewId), eq(reviewVotes.userId, userId)));1415 if (existing) {16 if (existing.isHelpful === isHelpful) {17 await db.delete(reviewVotes).where(eq(reviewVotes.id, existing.id));18 await db.update(reviews)19 .set({ helpfulCount: sql`helpful_count - 1` })20 .where(eq(reviews.id, reviewId));21 return res.json({ voted: false });22 }23 await db.update(reviewVotes).set({ isHelpful }).where(eq(reviewVotes.id, existing.id));24 } else {25 await db.insert(reviewVotes).values({ reviewId, userId, isHelpful });26 if (isHelpful) {27 await db.update(reviews)28 .set({ helpfulCount: sql`helpful_count + 1` })29 .where(eq(reviews.id, reviewId));30 }31 }32 res.json({ voted: true, isHelpful });33});3435// GET /api/items/:id/rating-distribution36router.get('/items/:id/rating-distribution', async (req, res) => {37 const itemId = parseInt(req.params.id);38 const result = await db.execute(sql`39 SELECT gs.star_level,40 COALESCE(r.count, 0)::integer AS count,41 CASE WHEN total.total_count > 042 THEN ROUND(COALESCE(r.count, 0)::numeric / total.total_count * 100, 1)43 ELSE 0 END AS percentage44 FROM generate_series(5, 1, -1) AS gs(star_level)45 LEFT JOIN (SELECT rating, COUNT(*) AS count FROM reviews46 WHERE item_id = ${itemId} GROUP BY rating) r ON r.rating = gs.star_level47 CROSS JOIN (SELECT COUNT(*) AS total_count FROM reviews48 WHERE item_id = ${itemId}) total49 ORDER BY gs.star_level DESC50 `);51 res.json(result.rows);52});5354module.exports = router;Pro tip: generate_series(5, 1, -1) ensures all 5 star levels are always present in the response. Without it, a new item with no 1-star or 2-star reviews would return an array shorter than 5 items, breaking your frontend bar chart.
Expected result: GET /api/items/1/rating-distribution returns 5 objects with star_level, count, and percentage. POST /api/reviews/1/vote toggles helpful status — clicking twice removes the vote.
Complete code
1const express = require('express');2const { reviews, reviewVotes } = require('../../shared/schema');3const { eq, and, desc, sql } = require('drizzle-orm');4const { db } = require('../db');56const router = express.Router();78router.get('/items/:id/reviews', async (req, res) => {9 const itemId = parseInt(req.params.id);10 const page = parseInt(req.query.page) || 1;11 const limit = parseInt(req.query.limit) || 10;12 const sort = req.query.sort || 'recent';13 const orderMap = {14 recent: desc(reviews.createdAt),15 helpful: desc(reviews.helpfulCount),16 rating_high: desc(reviews.rating),17 rating_low: reviews.rating,18 };19 const rows = await db.select().from(reviews)20 .where(eq(reviews.itemId, itemId))21 .orderBy(orderMap[sort] || desc(reviews.createdAt))22 .limit(limit).offset((page - 1) * limit);23 res.json({ reviews: rows, page, limit });24});2526router.post('/items/:id/reviews', async (req, res) => {27 const userId = req.user?.id;28 if (!userId) return res.status(401).json({ error: 'Login required' });29 const { rating, title, body, pros, cons } = req.body;30 if (!rating || rating < 1 || rating > 5)31 return res.status(400).json({ error: 'Rating must be 1-5' });32 try {33 const [review] = await db.insert(reviews).values({34 itemId: parseInt(req.params.id), userId,35 rating: parseInt(rating), title: title || null,36 body: body || null, pros: pros || null, cons: cons || null,37 }).returning();38 res.status(201).json(review);39 } catch (err) {40 if (err.code === '23505') return res.status(409).json({ error: 'Already reviewed' });41 res.status(500).json({ error: err.message });42 }43});4445router.put('/reviews/:id', async (req, res) => {46 const userId = req.user?.id;47 if (!userId) return res.status(401).json({ error: 'Login required' });48 const { rating, title, body, pros, cons } = req.body;49 const [updated] = await db.update(reviews)50 .set({ rating: parseInt(rating), title, body, pros, cons })51 .where(and(eq(reviews.id, parseInt(req.params.id)), eq(reviews.userId, userId)))52 .returning();53 if (!updated) return res.status(404).json({ error: 'Review not found or not yours' });54 res.json(updated);55});5657module.exports = router;Customization ideas
Verified purchase badge
Cross-reference reviews against an orders table. After a user completes a purchase, set is_verified = true on their review for that product. Display a 'Verified Buyer' badge on verified reviews to increase trust.
Image uploads on reviews
Allow reviewers to attach photos. Add a review_images table with review_id FK and image_url column. Use an external storage provider like Cloudinary to store images and link URLs to the review record.
Moderation queue
Add an is_approved boolean (default false) to the reviews table and an admin-only GET /api/admin/reviews/pending route. Reviews only appear in public listings after an admin sets is_approved = true, preventing spam and inappropriate content.
Owner response
Add owner_response text and owner_responded_at timestamp columns to reviews. Add a POST /api/reviews/:id/respond route gated to item owners. Display the response directly below the original review in the UI.
Common pitfalls
Pitfall: Recalculating average_rating in application code on every review write
How to avoid: Use a PostgreSQL trigger on the reviews table. The trigger runs inside the same database transaction as the review insert, making it atomic and race-condition safe.
Pitfall: Using a simple integer counter for helpful votes instead of a votes table
How to avoid: Use a review_votes table with a UNIQUE constraint on (review_id, user_id). Before inserting, check for an existing vote. If the same vote type exists, delete it (toggle off). If different, update it.
Pitfall: Allowing multiple reviews per user per item without a database constraint
How to avoid: Add a UNIQUE constraint on (item_id, user_id) at the database level. Catch error code 23505 in the route and return a 409 response — the database is the authoritative guard.
Best practices
- Use a PostgreSQL trigger to maintain average_rating and review_count — never recalculate aggregates in application code where race conditions can corrupt data.
- Store Replit Auth user IDs in the user_id column and use them for ownership checks in PUT and DELETE routes — never trust a user_id sent in the request body.
- Use the UNIQUE constraint on (item_id, user_id) as the primary defense against duplicate reviews, not application-level SELECT-then-INSERT checks.
- Always validate that rating is an integer between 1 and 5 before inserting — return a 400 with a clear message rather than letting a constraint error bubble up as a 500.
- Use generate_series in the rating distribution query to always return all 5 star levels — prevents frontend rendering errors when a star level has zero reviews.
- Deploy on Autoscale — review pages are read-heavy with spiky traffic and benefit from Replit's scaling model. Pre-computed aggregates on items make reads fast at scale.
- Use Drizzle Studio (built into Replit, accessible from the database panel) to inspect the items table and verify that average_rating updates correctly after each review insert.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a reviews and ratings system with Express, PostgreSQL, and Drizzle ORM on Replit. I have three tables: items (with average_rating and review_count columns), reviews (with a unique constraint on item_id + user_id), and review_votes (for helpful votes, unique on review_id + user_id). Help me write a PostgreSQL trigger function that automatically recalculates average_rating and review_count on the items table after any INSERT, UPDATE, or DELETE on the reviews table. Use COALESCE so average_rating resets to 0 if all reviews are deleted.
Add a spam detection layer to the reviews system on Replit. After each review is submitted, check whether the same user_id has submitted more than 3 reviews in the last 24 hours across all items using a single SQL COUNT query with a WHERE created_at > NOW() - INTERVAL '24 hours' condition. If the threshold is exceeded, set a is_flagged boolean to true and exclude flagged reviews from the public GET /api/items/:id/reviews listing. Add a GET /api/admin/reviews/flagged route for the moderation queue.
Frequently asked questions
How does the star rating aggregate stay accurate without manual updates?
A PostgreSQL trigger fires after every INSERT, UPDATE, or DELETE on the reviews table. It runs SELECT AVG(rating) and COUNT(*) scoped to the affected item_id and writes the results back to items.average_rating and items.review_count. Because it runs inside the same transaction as the review write, it is always consistent.
Can I use this for any type of item — not just products?
Yes. The items table has a type column with values product, service, business, and location. The review and rating logic is identical regardless of item type. Change the values you seed into the items table and update the frontend labels — the API stays the same.
What prevents someone from leaving multiple reviews for the same item?
A UNIQUE constraint on (item_id, user_id) in the reviews table. If a user tries to insert a second review, PostgreSQL raises error code 23505. The POST route catches this and returns a 409 Conflict with the message 'You have already reviewed this item'.
What Replit plan do I need?
The free tier is sufficient. This build uses Express, PostgreSQL (built into Replit), and Replit Auth — all available on free accounts. Deploy on Autoscale, which is available on all plans.
Should I deploy on Autoscale or Reserved VM?
Autoscale. Review pages are read-heavy — most visitors read reviews rather than write them. Autoscale handles bursty read traffic well and scales to zero during quiet periods. Reserved VM is only needed for apps with webhooks or WebSocket connections, which this build does not use.
How do I mark certain reviews as Verified Buyer?
Cross-reference the reviews table against an orders table. After an order is marked complete, run an UPDATE on reviews setting is_verified = true where item_id and user_id match the order. The is_verified column is already in the schema — you just need to populate it from your order fulfillment logic.
Can RapidDev help build a custom reviews and ratings system?
Yes. RapidDev has built 600+ apps including review systems with verified purchases, moderation queues, owner responses, and spam detection. If your use case needs features beyond this guide, book a free consultation at rapidevelopers.com.
How do I prevent the same user from voting on a review multiple times?
The review_votes table has a UNIQUE constraint on (review_id, user_id). The vote route checks for an existing vote before inserting. If the same vote type already exists, it deletes the vote (toggle off). If a different vote type exists, it updates the record. This makes the helpful button a true toggle with no duplicates possible at the database level.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation