# How to Build a Directory Service with Replit

- Tool: How to Build with Replit
- Difficulty: Beginner
- Compatibility: Replit Free
- Last updated: April 2026

## 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.

## Before you start

- 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

## Step-by-step guide

### 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 to type into Replit Agent:
// Build a directory service app with Express and PostgreSQL using Drizzle ORM.
// Create these tables in shared/schema.ts:
// - categories: id serial pk, name text unique, slug text unique, icon text, description text, position integer default 0
// - listings: id serial pk, title text, slug text unique, description text,
//   category_id integer references categories, website_url text, contact_email text,
//   location text, tags text[] (PostgreSQL array), image_url text,
//   status text default 'pending' (pending/approved/rejected/featured),
//   submitted_by text, approved_by text, approved_at timestamp,
//   featured boolean default false, view_count integer default 0, created_at timestamp
// - reviews: id serial pk, listing_id integer references listings,
//   user_id text, rating integer (1-5), comment text, created_at timestamp,
//   UNIQUE constraint on (listing_id, user_id)
// - saved_listings: id serial pk, user_id text, listing_id integer references listings,
//   created_at timestamp, UNIQUE constraint on (user_id, listing_id)
// Add a GIN index on to_tsvector('english', listings.title || ' ' || listings.description)
// 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.

```
const { db } = require('../db');
const { listings, categories, reviews } = require('../../shared/schema');
const { sql, eq, and, inArray } = require('drizzle-orm');

router.get('/api/listings', async (req, res) => {
  try {
    const { q, category, location, page = 1, limit = 20 } = req.query;
    const offset = (Number(page) - 1) * Number(limit);

    const conditions = [eq(listings.status, 'approved')];

    if (category) {
      const cat = await db.query.categories.findFirst({ where: eq(categories.slug, category) });
      if (cat) conditions.push(eq(listings.categoryId, cat.id));
    }

    if (location) {
      conditions.push(sql`listings.location ILIKE ${'%' + location + '%'}`);
    }

    let query;
    if (q) {
      // Full-text search with relevance ranking
      query = await db.execute(
        sql`SELECT l.*, c.name AS category_name,
              COALESCE(AVG(r.rating), 0) AS avg_rating,
              COUNT(r.id) AS review_count,
              ts_rank(to_tsvector('english', l.title || ' ' || COALESCE(l.description, '')),
                plainto_tsquery('english', ${q})) AS rank
            FROM listings l
            LEFT JOIN categories c ON c.id = l.category_id
            LEFT JOIN reviews r ON r.listing_id = l.id
            WHERE ${and(...conditions)}
              AND to_tsvector('english', l.title || ' ' || COALESCE(l.description, ''))
                @@ plainto_tsquery('english', ${q})
            GROUP BY l.id, c.name
            ORDER BY rank DESC, l.featured DESC
            LIMIT ${Number(limit)} OFFSET ${offset}`
      );
    } else {
      query = await db.execute(
        sql`SELECT l.*, c.name AS category_name,
              COALESCE(AVG(r.rating), 0) AS avg_rating,
              COUNT(r.id) AS review_count
            FROM listings l
            LEFT JOIN categories c ON c.id = l.category_id
            LEFT JOIN reviews r ON r.listing_id = l.id
            WHERE ${and(...conditions)}
            GROUP BY l.id, c.name
            ORDER BY l.featured DESC, l.created_at DESC
            LIMIT ${Number(limit)} OFFSET ${offset}`
      );
    }

    res.json(query.rows);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});
```

> 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 to type into Replit Agent:
// Add these routes to server/routes/listings.js:
//
// POST /api/listings — create a new listing, requires auth
//   Set status = 'pending', submitted_by = req.user.id
//   Auto-generate slug from title (lowercase, spaces to hyphens, strip special chars)
//   Return the created listing
//
// GET /api/listings/:slug — public, detail page
//   Join with categories and reviews, include avg_rating and review_count
//   Atomically increment view_count: UPDATE listings SET view_count = view_count + 1
//   Return listing + reviews array
//
// GET /api/listings/mine — requires auth
//   Return all listings where submitted_by = req.user.id, include status
//
// GET /api/admin/listings?status=pending — requires auth (admin check)
//   List listings filtered by status, include submitter info
//
// PATCH /api/admin/listings/:id/approve — admin only
//   Set status = 'approved', approved_by = req.user.id, approved_at = now()
//
// PATCH /api/admin/listings/:id/reject — admin only
//   Set status = 'rejected', approved_by = req.user.id
//
// PATCH /api/admin/listings/:id/feature — admin only
//   Toggle featured boolean
//
// For admin check: store admin user IDs in process.env.ADMIN_USER_IDS (comma-separated)
// 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.

```
const { db } = require('../db');
const { reviews, savedListings, listings } = require('../../shared/schema');
const { eq, and } = require('drizzle-orm');

// POST /api/items/:id/reviews — add or update a review
router.post('/api/listings/:id/reviews', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });

  const { rating, comment } = req.body;
  if (!rating || rating < 1 || rating > 5) {
    return res.status(400).json({ error: 'Rating must be 1-5' });
  }

  try {
    // Upsert: insert or update if user already reviewed this listing
    const review = await db.insert(reviews).values({
      listingId: Number(req.params.id),
      userId: req.user.id,
      rating: Number(rating),
      comment
    }).onConflictDoUpdate({
      target: [reviews.listingId, reviews.userId],
      set: { rating: Number(rating), comment }
    }).returning();

    res.json(review[0]);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// POST /api/listings/:id/save — toggle saved
router.post('/api/listings/:id/save', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });

  const existing = await db.query.savedListings.findFirst({
    where: and(eq(savedListings.userId, req.user.id), eq(savedListings.listingId, Number(req.params.id)))
  });

  if (existing) {
    await db.delete(savedListings).where(eq(savedListings.id, existing.id));
    return res.json({ saved: false });
  }

  await db.insert(savedListings).values({ userId: req.user.id, listingId: Number(req.params.id) });
  res.json({ saved: true });
});

// GET /api/saved — user's saved listings
router.get('/api/saved', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const saved = await db.query.savedListings.findMany({
    where: eq(savedListings.userId, req.user.id),
    with: { listing: true }
  });
  res.json(saved.map(s => s.listing));
});
```

**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 to type into Replit Agent:
// Create a seed script at scripts/seed.js that inserts initial categories:
// const categories = [
//   { name: 'Technology', slug: 'technology', icon: '💻', description: 'Tech tools and services' },
//   { name: 'Food & Drink', slug: 'food-drink', icon: '🍕', description: 'Restaurants and cafes' },
//   { name: 'Health', slug: 'health', icon: '🏥', description: 'Healthcare and wellness' },
//   { name: 'Education', slug: 'education', icon: '📚', description: 'Learning resources' },
//   { name: 'Services', slug: 'services', icon: '🔧', description: 'Professional services' }
// ]
// Run: await db.insert(categories).values(categories).onConflictDoNothing()
// Then add to package.json scripts: "seed": "node scripts/seed.js"
//
// Also add the PostgreSQL retry wrapper to server/db.js (Pool with connectionTimeoutMillis: 5000)
// Ensure server binds to 0.0.0.0
// 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 example

File: `server/routes/listings.js`

```javascript
const { Router } = require('express');
const { db } = require('../db');
const { listings, categories, reviews, savedListings } = require('../../shared/schema');
const { sql, eq, and } = require('drizzle-orm');

const router = Router();

function slugify(text) {
  return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}

router.get('/api/listings', async (req, res) => {
  const { q, category, page = 1, limit = 20 } = req.query;
  const offset = (Number(page) - 1) * Number(limit);

  try {
    const baseCondition = sql`l.status = 'approved'`;
    const catCondition = category ? sql`c.slug = ${category}` : sql`TRUE`;
    const searchCondition = q
      ? sql`to_tsvector('english', l.title || ' ' || COALESCE(l.description,''))
            @@ plainto_tsquery('english', ${q})`
      : sql`TRUE`;

    const result = await db.execute(
      sql`SELECT l.id, l.title, l.slug, l.description, l.location, l.image_url,
              l.featured, l.view_count, l.created_at, c.name AS category_name,
              COALESCE(AVG(r.rating), 0) AS avg_rating,
              COUNT(DISTINCT r.id) AS review_count
          FROM listings l
          LEFT JOIN categories c ON c.id = l.category_id
          LEFT JOIN reviews r ON r.listing_id = l.id
          WHERE ${baseCondition} AND ${catCondition} AND ${searchCondition}
          GROUP BY l.id, c.name
          ORDER BY l.featured DESC, l.created_at DESC
          LIMIT ${Number(limit)} OFFSET ${offset}`
    );

    res.json(result.rows);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

router.post('/api/listings', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const { title, description, categoryId, websiteUrl, contactEmail, location } = req.body;
  const slug = slugify(title) + '-' + Date.now();
  const [listing] = await db.insert(listings)
    .values({ title, slug, description, categoryId, websiteUrl, contactEmail, location,
              submittedBy: req.user.id, status: 'pending' })
    .returning();
  res.json(listing);
});

module.exports = router;
```

## Common mistakes

- **Making all listings publicly visible immediately on submission** — Without moderation, spammers and low-quality submissions appear in search results, eroding trust in the directory. Fix: 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.
- **Querying listings without the GIN index for full-text search** — Without the GIN index, PostgreSQL does a sequential scan of the entire listings table for every search query. With 10,000 listings this takes seconds. Fix: Create the GIN index during migration: CREATE INDEX listings_search_idx ON listings USING GIN(to_tsvector('english', title || ' ' || description)).
- **Incrementing view_count in a separate UPDATE after the SELECT** — Two simultaneous requests reading the same listing would both read view_count=50 and both write 51, losing one increment. Fix: 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.

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-replit/directory-service
© RapidDev — https://www.rapidevelopers.com/how-to-build-replit/directory-service
