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
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
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.
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 05// - 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 timestamp11// - 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.
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.
1const { db } = require('../db');2const { listings, categories, reviews } = require('../../shared/schema');3const { sql, eq, and, inArray } = require('drizzle-orm');45router.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);910 const conditions = [eq(listings.status, 'approved')];1112 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 }1617 if (location) {18 conditions.push(sql`listings.location ILIKE ${'%' + location + '%'}`);19 }2021 let query;22 if (q) {23 // Full-text search with relevance ranking24 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 rank30 FROM listings l31 LEFT JOIN categories c ON c.id = l.category_id32 LEFT JOIN reviews r ON r.listing_id = l.id33 WHERE ${and(...conditions)}34 AND to_tsvector('english', l.title || ' ' || COALESCE(l.description, ''))35 @@ plainto_tsquery('english', ${q})36 GROUP BY l.id, c.name37 ORDER BY rank DESC, l.featured DESC38 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_count45 FROM listings l46 LEFT JOIN categories c ON c.id = l.category_id47 LEFT JOIN reviews r ON r.listing_id = l.id48 WHERE ${and(...conditions)}49 GROUP BY l.id, c.name50 ORDER BY l.featured DESC, l.created_at DESC51 LIMIT ${Number(limit)} OFFSET ${offset}`52 );53 }5455 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.
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.
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 auth5// Set status = 'pending', submitted_by = req.user.id6// Auto-generate slug from title (lowercase, spaces to hyphens, strip special chars)7// Return the created listing8//9// GET /api/listings/:slug — public, detail page10// Join with categories and reviews, include avg_rating and review_count11// Atomically increment view_count: UPDATE listings SET view_count = view_count + 112// Return listing + reviews array13//14// GET /api/listings/mine — requires auth15// Return all listings where submitted_by = req.user.id, include status16//17// GET /api/admin/listings?status=pending — requires auth (admin check)18// List listings filtered by status, include submitter info19//20// PATCH /api/admin/listings/:id/approve — admin only21// Set status = 'approved', approved_by = req.user.id, approved_at = now()22//23// PATCH /api/admin/listings/:id/reject — admin only24// Set status = 'rejected', approved_by = req.user.id25//26// PATCH /api/admin/listings/:id/feature — admin only27// Toggle featured boolean28//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 listPro 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.
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.
1const { db } = require('../db');2const { reviews, savedListings, listings } = require('../../shared/schema');3const { eq, and } = require('drizzle-orm');45// POST /api/items/:id/reviews — add or update a review6router.post('/api/listings/:id/reviews', async (req, res) => {7 if (!req.user) return res.status(401).json({ error: 'Login required' });89 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 }1314 try {15 // Upsert: insert or update if user already reviewed this listing16 const review = await db.insert(reviews).values({17 listingId: Number(req.params.id),18 userId: req.user.id,19 rating: Number(rating),20 comment21 }).onConflictDoUpdate({22 target: [reviews.listingId, reviews.userId],23 set: { rating: Number(rating), comment }24 }).returning();2526 res.json(review[0]);27 } catch (err) {28 res.status(500).json({ error: err.message });29 }30});3132// POST /api/listings/:id/save — toggle saved33router.post('/api/listings/:id/save', async (req, res) => {34 if (!req.user) return res.status(401).json({ error: 'Login required' });3536 const existing = await db.query.savedListings.findFirst({37 where: and(eq(savedListings.userId, req.user.id), eq(savedListings.listingId, Number(req.params.id)))38 });3940 if (existing) {41 await db.delete(savedListings).where(eq(savedListings.id, existing.id));42 return res.json({ saved: false });43 }4445 await db.insert(savedListings).values({ userId: req.user.id, listingId: Number(req.params.id) });46 res.json({ saved: true });47});4849// GET /api/saved — user's saved listings50router.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.
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.
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.015// 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
1const { Router } = require('express');2const { db } = require('../db');3const { listings, categories, reviews, savedListings } = require('../../shared/schema');4const { sql, eq, and } = require('drizzle-orm');56const router = Router();78function slugify(text) {9 return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');10}1112router.get('/api/listings', async (req, res) => {13 const { q, category, page = 1, limit = 20 } = req.query;14 const offset = (Number(page) - 1) * Number(limit);1516 try {17 const baseCondition = sql`l.status = 'approved'`;18 const catCondition = category ? sql`c.slug = ${category}` : sql`TRUE`;19 const searchCondition = q20 ? sql`to_tsvector('english', l.title || ' ' || COALESCE(l.description,''))21 @@ plainto_tsquery('english', ${q})`22 : sql`TRUE`;2324 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_count29 FROM listings l30 LEFT JOIN categories c ON c.id = l.category_id31 LEFT JOIN reviews r ON r.listing_id = l.id32 WHERE ${baseCondition} AND ${catCondition} AND ${searchCondition}33 GROUP BY l.id, c.name34 ORDER BY l.featured DESC, l.created_at DESC35 LIMIT ${Number(limit)} OFFSET ${offset}`36 );3738 res.json(result.rows);39 } catch (err) {40 res.status(500).json({ error: err.message });41 }42});4344router.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});5455module.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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation