Skip to main content
RapidDev - Software Development Agency
how-to-build-replit30-60 minutes

How to Build a Reviews & Ratings with Replit

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'll build

  • RESTful API with Express routes for creating, editing, and deleting reviews per item
  • PostgreSQL database with Drizzle ORM tables for items, reviews, and helpful votes
  • Automatic aggregate score updates via a PostgreSQL trigger on review insert/update/delete
  • One-review-per-user enforcement using a unique constraint on item_id + user_id
  • Helpful vote system with a separate votes table to prevent duplicate voting
  • Paginated review listing sortable by most recent, most helpful, rating high, and rating low
  • Rating distribution endpoint returning per-star counts for bar chart display
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner13 min read30-60 minutesReplit FreeApril 2026RapidDev Engineering Team
TL;DR

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

ReplitIDE & Hosting
ExpressBackend Framework
PostgreSQLDatabase
Drizzle ORMDatabase ORM
Replit AuthAuth

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

1

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.

prompt.txt
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_count
18// GET /api/items/:id — detail
19// GET /api/items/:id/reviews — paginated, sort by recent/helpful/rating_high/rating_low
20// POST /api/items/:id/reviews — create, one per user per item
21// PUT /api/reviews/:id — edit own review
22// DELETE /api/reviews/:id — delete own review
23// POST /api/reviews/:id/vote — helpful toggle
24// GET /api/items/:id/rating-distribution — count per star level
25// 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.

2

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.

server/db/trigger.js
1// Run this after Drizzle migrations in your server startup file.
2// It installs the trigger function and attaches it to the reviews table.
3
4const { sql } = require('drizzle-orm');
5const { db } = require('./db');
6
7const triggerSQL = `
8 CREATE OR REPLACE FUNCTION update_item_rating_aggregates()
9 RETURNS TRIGGER AS $$
10 DECLARE
11 target_item_id INTEGER;
12 BEGIN
13 IF TG_OP = 'DELETE' THEN
14 target_item_id := OLD.item_id;
15 ELSE
16 target_item_id := NEW.item_id;
17 END IF;
18
19 UPDATE items
20 SET
21 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;
27
28 RETURN NULL;
29 END;
30 $$ LANGUAGE plpgsql;
31
32 DROP TRIGGER IF EXISTS reviews_aggregate_trigger ON reviews;
33 CREATE TRIGGER reviews_aggregate_trigger
34 AFTER INSERT OR UPDATE OR DELETE ON reviews
35 FOR EACH ROW
36 EXECUTE FUNCTION update_item_rating_aggregates();
37`;
38
39async function setupTrigger() {
40 await db.execute(sql.raw(triggerSQL));
41 console.log('Rating aggregate trigger installed');
42}
43
44setupTrigger().catch(console.error);
45
46module.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.

3

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.

server/routes/reviews.js
1const express = require('express');
2const { reviews } = require('../../shared/schema');
3const { eq } = require('drizzle-orm');
4const { db } = require('../db');
5
6const router = express.Router();
7
8// POST /api/items/:id/reviews
9router.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' });
12
13 const itemId = parseInt(req.params.id);
14 const { rating, title, body, pros, cons } = req.body;
15
16 if (!rating || rating < 1 || rating > 5) {
17 return res.status(400).json({ error: 'Rating must be between 1 and 5' });
18 }
19
20 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();
30
31 // PostgreSQL trigger automatically updates items.average_rating
32 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});
40
41module.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'.

4

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.

server/routes/votes.js
1const { reviewVotes, reviews } = require('../../shared/schema');
2const { eq, and, sql, desc } = require('drizzle-orm');
3
4// POST /api/reviews/:id/vote
5router.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' });
8
9 const reviewId = parseInt(req.params.id);
10 const { isHelpful } = req.body;
11
12 const [existing] = await db.select().from(reviewVotes)
13 .where(and(eq(reviewVotes.reviewId, reviewId), eq(reviewVotes.userId, userId)));
14
15 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});
34
35// GET /api/items/:id/rating-distribution
36router.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 > 0
42 THEN ROUND(COALESCE(r.count, 0)::numeric / total.total_count * 100, 1)
43 ELSE 0 END AS percentage
44 FROM generate_series(5, 1, -1) AS gs(star_level)
45 LEFT JOIN (SELECT rating, COUNT(*) AS count FROM reviews
46 WHERE item_id = ${itemId} GROUP BY rating) r ON r.rating = gs.star_level
47 CROSS JOIN (SELECT COUNT(*) AS total_count FROM reviews
48 WHERE item_id = ${itemId}) total
49 ORDER BY gs.star_level DESC
50 `);
51 res.json(result.rows);
52});
53
54module.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

server/routes/reviews.js
1const express = require('express');
2const { reviews, reviewVotes } = require('../../shared/schema');
3const { eq, and, desc, sql } = require('drizzle-orm');
4const { db } = require('../db');
5
6const router = express.Router();
7
8router.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});
25
26router.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});
44
45router.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});
56
57module.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.

ChatGPT Prompt

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.

Build Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

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.