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

How to Build a Directory Service with Replit

Build a Yelp-style directory in Replit in 30-60 minutes. Users submit categorized listings, admins moderate submissions, and visitors browse with full-text PostgreSQL search. Features star ratings, saved listings, and featured spots. Uses Express, PostgreSQL with Drizzle ORM, and Replit Auth — no external services required.

What you'll build

  • Categorized listing database with full-text search using PostgreSQL tsvector and GIN index
  • User submission flow with pending/approved/rejected moderation status
  • Star rating system with one-review-per-user constraint enforced at the database level
  • Admin moderation queue for approving and featuring listings
  • Saved listings bookmark feature for logged-in users
  • View count tracking that increments on each listing detail page load
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 Yelp-style directory in Replit in 30-60 minutes. Users submit categorized listings, admins moderate submissions, and visitors browse with full-text PostgreSQL search. Features star ratings, saved listings, and featured spots. Uses Express, PostgreSQL with Drizzle ORM, and Replit Auth — no external services required.

What you're building

Directories solve a universal problem: people need a curated, searchable list of resources in a specific niche. Whether it's local restaurants, developer tools, remote work resources, or SaaS alternatives — a directory site attracts organic search traffic and builds community. This project gives you a full-featured directory you own and control.

Replit Agent generates the entire backend from a single prompt: Express routes, Drizzle schema with proper indexes, Replit Auth middleware, and a moderation workflow. The full-text search uses PostgreSQL's built-in tsvector with a GIN index — no external search service needed. This keeps the stack minimal and the monthly cost at zero on Replit's free tier.

The architecture follows a simple pattern: listings go through a pending → approved flow before becoming public, users can review and bookmark listings, and a featured flag lets admins highlight top entries. Everything runs on Replit's built-in PostgreSQL and deploys on Autoscale, which scales to zero between traffic spikes.

Final result

A production-ready directory with full-text search, user submissions, moderation workflow, star ratings, and saved listings — all running on Replit's built-in PostgreSQL.

Tech stack

ReplitIDE & Hosting
ExpressBackend Framework
PostgreSQLDatabase
Drizzle ORMDatabase ORM
Replit AuthAuth

Prerequisites

  • A Replit account (free tier is sufficient)
  • A clear niche for your directory (what types of listings will you feature?)
  • Basic understanding of what a web form and a database table are (no coding experience needed)
  • No external API keys required

Build steps

1

Generate the project with Replit Agent

Use Replit Agent to scaffold the entire backend in one prompt. This includes the Drizzle schema with proper indexes, all Express routes, and Replit Auth setup. Starting with a complete schema saves hours of iteration.

prompt.txt
1// Prompt to type into Replit Agent:
2// Build a directory service app with Express and PostgreSQL using Drizzle ORM.
3// Create these tables in shared/schema.ts:
4// - categories: id serial pk, name text unique, slug text unique, icon text, description text, position integer default 0
5// - listings: id serial pk, title text, slug text unique, description text,
6// category_id integer references categories, website_url text, contact_email text,
7// location text, tags text[] (PostgreSQL array), image_url text,
8// status text default 'pending' (pending/approved/rejected/featured),
9// submitted_by text, approved_by text, approved_at timestamp,
10// featured boolean default false, view_count integer default 0, created_at timestamp
11// - reviews: id serial pk, listing_id integer references listings,
12// user_id text, rating integer (1-5), comment text, created_at timestamp,
13// UNIQUE constraint on (listing_id, user_id)
14// - saved_listings: id serial pk, user_id text, listing_id integer references listings,
15// created_at timestamp, UNIQUE constraint on (user_id, listing_id)
16// Add a GIN index on to_tsvector('english', listings.title || ' ' || listings.description)
17// Set up Replit Auth middleware. Bind server to 0.0.0.0.

Pro tip: After Agent runs, open the Replit database panel and verify all four tables exist before proceeding. If any are missing, ask Agent to add them individually.

Expected result: Agent creates shared/schema.ts with all tables, server/index.js with route stubs, and the database gets initialized with the schema via Drizzle migrations.

2

Build the full-text search and browse API

The search and browse endpoint is the most-used route in any directory. It combines PostgreSQL full-text search with category and tag filters in a single query, returning relevance-ranked results.

server/routes/listings.js
1const { db } = require('../db');
2const { listings, categories, reviews } = require('../../shared/schema');
3const { sql, eq, and, inArray } = require('drizzle-orm');
4
5router.get('/api/listings', async (req, res) => {
6 try {
7 const { q, category, location, page = 1, limit = 20 } = req.query;
8 const offset = (Number(page) - 1) * Number(limit);
9
10 const conditions = [eq(listings.status, 'approved')];
11
12 if (category) {
13 const cat = await db.query.categories.findFirst({ where: eq(categories.slug, category) });
14 if (cat) conditions.push(eq(listings.categoryId, cat.id));
15 }
16
17 if (location) {
18 conditions.push(sql`listings.location ILIKE ${'%' + location + '%'}`);
19 }
20
21 let query;
22 if (q) {
23 // Full-text search with relevance ranking
24 query = await db.execute(
25 sql`SELECT l.*, c.name AS category_name,
26 COALESCE(AVG(r.rating), 0) AS avg_rating,
27 COUNT(r.id) AS review_count,
28 ts_rank(to_tsvector('english', l.title || ' ' || COALESCE(l.description, '')),
29 plainto_tsquery('english', ${q})) AS rank
30 FROM listings l
31 LEFT JOIN categories c ON c.id = l.category_id
32 LEFT JOIN reviews r ON r.listing_id = l.id
33 WHERE ${and(...conditions)}
34 AND to_tsvector('english', l.title || ' ' || COALESCE(l.description, ''))
35 @@ plainto_tsquery('english', ${q})
36 GROUP BY l.id, c.name
37 ORDER BY rank DESC, l.featured DESC
38 LIMIT ${Number(limit)} OFFSET ${offset}`
39 );
40 } else {
41 query = await db.execute(
42 sql`SELECT l.*, c.name AS category_name,
43 COALESCE(AVG(r.rating), 0) AS avg_rating,
44 COUNT(r.id) AS review_count
45 FROM listings l
46 LEFT JOIN categories c ON c.id = l.category_id
47 LEFT JOIN reviews r ON r.listing_id = l.id
48 WHERE ${and(...conditions)}
49 GROUP BY l.id, c.name
50 ORDER BY l.featured DESC, l.created_at DESC
51 LIMIT ${Number(limit)} OFFSET ${offset}`
52 );
53 }
54
55 res.json(query.rows);
56 } catch (err) {
57 res.status(500).json({ error: err.message });
58 }
59});

Pro tip: The GIN index makes tsvector queries fast even with thousands of listings. Without it, full-text search does a sequential scan. Ask Agent to run CREATE INDEX listings_search_idx ON listings USING GIN(to_tsvector('english', title || ' ' || description)) in the initial migration.

Expected result: GET /api/listings?q=coffee returns relevance-ranked listings matching the search term. GET /api/listings?category=restaurants returns approved listings in that category.

3

Add listing submission and moderation routes

New listings start as 'pending' and only appear publicly after an admin approves them. This prevents spam and ensures quality. The admin moderation queue shows all pending submissions.

prompt.txt
1// Prompt to type into Replit Agent:
2// Add these routes to server/routes/listings.js:
3//
4// POST /api/listings — create a new listing, requires auth
5// Set status = 'pending', submitted_by = req.user.id
6// Auto-generate slug from title (lowercase, spaces to hyphens, strip special chars)
7// Return the created listing
8//
9// GET /api/listings/:slug — public, detail page
10// Join with categories and reviews, include avg_rating and review_count
11// Atomically increment view_count: UPDATE listings SET view_count = view_count + 1
12// Return listing + reviews array
13//
14// GET /api/listings/mine — requires auth
15// Return all listings where submitted_by = req.user.id, include status
16//
17// GET /api/admin/listings?status=pending — requires auth (admin check)
18// List listings filtered by status, include submitter info
19//
20// PATCH /api/admin/listings/:id/approve — admin only
21// Set status = 'approved', approved_by = req.user.id, approved_at = now()
22//
23// PATCH /api/admin/listings/:id/reject — admin only
24// Set status = 'rejected', approved_by = req.user.id
25//
26// PATCH /api/admin/listings/:id/feature — admin only
27// Toggle featured boolean
28//
29// For admin check: store admin user IDs in process.env.ADMIN_USER_IDS (comma-separated)
30// and check if req.user.id is in that list

Pro tip: Store your own Replit user ID in the ADMIN_USER_IDS secret (lock icon in sidebar). Find your user ID from the Replit Auth session object by logging it temporarily after authentication.

Expected result: New listing submissions show up in GET /api/admin/listings?status=pending. After approval they appear in the public browse and search results.

4

Add reviews and saved listings

The unique constraint on (listing_id, user_id) in the reviews table enforces one review per user per listing at the database level — no application logic needed. Saved listings work the same way.

server/routes/listings.js
1const { db } = require('../db');
2const { reviews, savedListings, listings } = require('../../shared/schema');
3const { eq, and } = require('drizzle-orm');
4
5// POST /api/items/:id/reviews — add or update a review
6router.post('/api/listings/:id/reviews', async (req, res) => {
7 if (!req.user) return res.status(401).json({ error: 'Login required' });
8
9 const { rating, comment } = req.body;
10 if (!rating || rating < 1 || rating > 5) {
11 return res.status(400).json({ error: 'Rating must be 1-5' });
12 }
13
14 try {
15 // Upsert: insert or update if user already reviewed this listing
16 const review = await db.insert(reviews).values({
17 listingId: Number(req.params.id),
18 userId: req.user.id,
19 rating: Number(rating),
20 comment
21 }).onConflictDoUpdate({
22 target: [reviews.listingId, reviews.userId],
23 set: { rating: Number(rating), comment }
24 }).returning();
25
26 res.json(review[0]);
27 } catch (err) {
28 res.status(500).json({ error: err.message });
29 }
30});
31
32// POST /api/listings/:id/save — toggle saved
33router.post('/api/listings/:id/save', async (req, res) => {
34 if (!req.user) return res.status(401).json({ error: 'Login required' });
35
36 const existing = await db.query.savedListings.findFirst({
37 where: and(eq(savedListings.userId, req.user.id), eq(savedListings.listingId, Number(req.params.id)))
38 });
39
40 if (existing) {
41 await db.delete(savedListings).where(eq(savedListings.id, existing.id));
42 return res.json({ saved: false });
43 }
44
45 await db.insert(savedListings).values({ userId: req.user.id, listingId: Number(req.params.id) });
46 res.json({ saved: true });
47});
48
49// GET /api/saved — user's saved listings
50router.get('/api/saved', async (req, res) => {
51 if (!req.user) return res.status(401).json({ error: 'Login required' });
52 const saved = await db.query.savedListings.findMany({
53 where: eq(savedListings.userId, req.user.id),
54 with: { listing: true }
55 });
56 res.json(saved.map(s => s.listing));
57});

Expected result: Reviewing a listing twice updates the existing review instead of creating a duplicate. The save endpoint toggles — calling it once saves, calling it again removes the bookmark.

5

Deploy and seed with initial categories

Deploy the app on Autoscale and seed the categories table with your directory's niche categories. Categories are the foundation of navigation, so set them up before sharing the directory URL.

prompt.txt
1// Prompt to type into Replit Agent:
2// Create a seed script at scripts/seed.js that inserts initial categories:
3// const categories = [
4// { name: 'Technology', slug: 'technology', icon: '💻', description: 'Tech tools and services' },
5// { name: 'Food & Drink', slug: 'food-drink', icon: '🍕', description: 'Restaurants and cafes' },
6// { name: 'Health', slug: 'health', icon: '🏥', description: 'Healthcare and wellness' },
7// { name: 'Education', slug: 'education', icon: '📚', description: 'Learning resources' },
8// { name: 'Services', slug: 'services', icon: '🔧', description: 'Professional services' }
9// ]
10// Run: await db.insert(categories).values(categories).onConflictDoNothing()
11// Then add to package.json scripts: "seed": "node scripts/seed.js"
12//
13// Also add the PostgreSQL retry wrapper to server/db.js (Pool with connectionTimeoutMillis: 5000)
14// Ensure server binds to 0.0.0.0
15// Deploy using Autoscale (Deploy button top-right → Autoscale)

Pro tip: Run the seed script from the Replit Shell tab (not terminal — use the built-in Shell icon) by typing: node scripts/seed.js. Alternatively, ask Agent to run it during the first server startup if the categories table is empty.

Expected result: The app is live at your deployment URL with categories populated. Submit a test listing and verify it appears in the admin moderation queue at /api/admin/listings?status=pending.

Complete code

server/routes/listings.js
1const { Router } = require('express');
2const { db } = require('../db');
3const { listings, categories, reviews, savedListings } = require('../../shared/schema');
4const { sql, eq, and } = require('drizzle-orm');
5
6const router = Router();
7
8function slugify(text) {
9 return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
10}
11
12router.get('/api/listings', async (req, res) => {
13 const { q, category, page = 1, limit = 20 } = req.query;
14 const offset = (Number(page) - 1) * Number(limit);
15
16 try {
17 const baseCondition = sql`l.status = 'approved'`;
18 const catCondition = category ? sql`c.slug = ${category}` : sql`TRUE`;
19 const searchCondition = q
20 ? sql`to_tsvector('english', l.title || ' ' || COALESCE(l.description,''))
21 @@ plainto_tsquery('english', ${q})`
22 : sql`TRUE`;
23
24 const result = await db.execute(
25 sql`SELECT l.id, l.title, l.slug, l.description, l.location, l.image_url,
26 l.featured, l.view_count, l.created_at, c.name AS category_name,
27 COALESCE(AVG(r.rating), 0) AS avg_rating,
28 COUNT(DISTINCT r.id) AS review_count
29 FROM listings l
30 LEFT JOIN categories c ON c.id = l.category_id
31 LEFT JOIN reviews r ON r.listing_id = l.id
32 WHERE ${baseCondition} AND ${catCondition} AND ${searchCondition}
33 GROUP BY l.id, c.name
34 ORDER BY l.featured DESC, l.created_at DESC
35 LIMIT ${Number(limit)} OFFSET ${offset}`
36 );
37
38 res.json(result.rows);
39 } catch (err) {
40 res.status(500).json({ error: err.message });
41 }
42});
43
44router.post('/api/listings', async (req, res) => {
45 if (!req.user) return res.status(401).json({ error: 'Login required' });
46 const { title, description, categoryId, websiteUrl, contactEmail, location } = req.body;
47 const slug = slugify(title) + '-' + Date.now();
48 const [listing] = await db.insert(listings)
49 .values({ title, slug, description, categoryId, websiteUrl, contactEmail, location,
50 submittedBy: req.user.id, status: 'pending' })
51 .returning();
52 res.json(listing);
53});
54
55module.exports = router;

Customization ideas

Claimed listings

Add a claimed_by field to listings. A 'Claim this listing' button lets business owners verify ownership (via email to contact_email). Claimed listings can be updated by the owner and show a verified badge.

Location-based filtering with map view

Add latitude and longitude columns to listings. Display approved listings as pins on a Leaflet map with OpenStreetMap tiles, with a bounding-box query to load only visible markers as the user pans the map.

Tag-based filtering

The tags column is already a PostgreSQL array. Add a tag filter UI that lets users select from a list of all unique tags and adds WHERE tags @> ARRAY['your-tag'] to the browse query.

Common pitfalls

Pitfall: Making all listings publicly visible immediately on submission

How to avoid: Always set status = 'pending' on creation. Only listings with status = 'approved' should appear in GET /api/listings. Build the admin queue first, before sharing the submission link.

Pitfall: Querying listings without the GIN index for full-text search

How to avoid: Create the GIN index during migration: CREATE INDEX listings_search_idx ON listings USING GIN(to_tsvector('english', title || ' ' || description)).

Pitfall: Incrementing view_count in a separate UPDATE after the SELECT

How to avoid: Use a single atomic UPDATE ... RETURNING: UPDATE listings SET view_count = view_count + 1 WHERE id = :id RETURNING *. This avoids race conditions.

Best practices

  • Auto-generate listing slugs on creation (lowercase title + timestamp suffix) so detail page URLs are clean and permanent even if the title is edited later.
  • Store your admin user IDs in Replit Secrets (ADMIN_USER_IDS as comma-separated values) rather than hardcoding them. This lets you add new admins without redeploying.
  • Use Replit Auth for listing submissions — it provides Google, GitHub, and email login with zero configuration. Non-logged-in users can still browse and search.
  • Enforce the one-review-per-user constraint at the database level with a UNIQUE constraint on (listing_id, user_id), not just in application code.
  • Use Drizzle Studio (database icon in Replit sidebar) to manage the moderation queue directly during the early days of your directory, before building the full admin UI.
  • Add a position column to categories and allow drag-to-reorder in the admin panel — the order categories appear in navigation significantly affects user browsing behavior.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a directory service with Express and PostgreSQL. I have a listings table with a tsvector column for full-text search. I need a single API endpoint that combines full-text search (using plainto_tsquery), category filtering by slug, location text search (ILIKE), and pagination (LIMIT/OFFSET). Help me write an efficient query that returns listings with their average star rating joined from a reviews table, without N+1 queries.

Build Prompt

Add a newsletter signup feature to the directory. Create a subscribers table (email, categories jsonb, created_at). A weekly Scheduled Deployment queries newly approved listings from the past 7 days, groups them by category, and sends a digest email via SendGrid to subscribers who chose those categories. Store SENDGRID_API_KEY in Replit Secrets.

Frequently asked questions

How do I find my Replit user ID to set as admin?

Temporarily add a route GET /api/me that returns req.user and call it after logging in. Your user ID appears in the JSON response. Copy it to the ADMIN_USER_IDS secret in the lock icon panel, then remove the /api/me route.

Can visitors submit listings without creating an account?

Yes, if you remove the auth check on POST /api/listings and store the submitter's email instead of user_id. However, unprotected submission forms attract spam quickly. Consider requiring Replit Auth login or adding a simple CAPTCHA via hCaptcha.

How do I handle listing images?

The listing schema has an image_url text field. For simple cases, accept a URL from the submitter. For file uploads, use Multer to handle the upload and store files in Replit Object Storage (available on free tier up to 1GB) or an external service like Cloudinary.

What's the difference between 'featured' and 'approved' status?

Approved means the listing passed moderation and is publicly visible in browse results. Featured is an additional boost — featured listings appear first in browse results regardless of date, and often get visual callout styling (star badge, highlighted card) in the frontend.

Do I need a paid Replit plan for this?

No. The free plan includes PostgreSQL, Autoscale deployment, and Replit Auth. The only limitation is the database sleeping after 5 minutes of inactivity, causing a brief cold-start delay on the first request after a quiet period.

How do I prevent spam submissions?

Three layers: require Replit Auth login to submit (one account per user), set new submissions to pending status by default so nothing appears publicly without review, and add rate limiting (max 5 submissions per user per day checked in the route handler).

Can RapidDev help me build a custom directory for my industry?

Yes. RapidDev has built 600+ apps and can add features like claimed listings, verified badges, featured listing payments with Stripe, and automated category email digests. Get a free consultation at rapidevelopers.com.

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.