# How to Build a Classifieds with Replit

- Tool: How to Build with Replit
- Difficulty: Intermediate
- Compatibility: Replit Core or higher
- Last updated: April 2026

## TL;DR

Build a Craigslist-style classifieds platform in Replit in 1-2 hours using Express, PostgreSQL, and Drizzle ORM. You'll get category-based listing creation, 30-day auto-expiration, full-text search, contact form with email relay, and a moderation queue for flagged listings.

## Before you start

- A Replit Core account (required for Replit Auth and built-in PostgreSQL)
- Decide on your category structure before building — changing the hierarchy after content is posted is disruptive
- Optional: a SendGrid or Resend API key for contact form email relay (free tier sufficient)

## Step-by-step guide

### 1. Scaffold the project with Agent

Generate the full Express + Drizzle project including the hierarchical category schema. The parent_id self-reference on categories is key — it enables subcategory nesting at any depth.

```
// Prompt to type into Replit Agent:
// Build a Node.js Express classifieds platform with Replit Auth and built-in PostgreSQL using Drizzle ORM.
// Schema in shared/schema.ts:
// * categories: id serial pk, name text not null unique, slug text not null unique,
//   parent_id integer references categories(id), icon text, position integer default 0
// * listings: id serial pk, category_id integer references categories not null,
//   title text not null, description text not null, price integer (in cents, null=free),
//   price_type text default 'fixed' (fixed/negotiable/free/contact),
//   condition text (new/like_new/good/fair/poor),
//   location text not null, images jsonb, contact_method text default 'email',
//   contact_email text, contact_phone text, poster_id text not null,
//   status text default 'active', expires_at timestamp, view_count integer default 0,
//   created_at timestamp default now()
// * saved_searches: id serial pk, user_id text not null, query text,
//   category_id integer, location text, min_price integer, max_price integer,
//   created_at timestamp default now()
// * flags: id serial pk, listing_id integer references listings not null,
//   reporter_id text not null, reason text not null, details text,
//   status text default 'pending', created_at timestamp default now()
// Routes: GET /api/listings, GET /api/listings/:id, POST /api/listings,
// PUT /api/listings/:id, PATCH /api/listings/:id/renew, PATCH /api/listings/:id/sold,
// DELETE /api/listings/:id, POST /api/listings/:id/flag,
// GET /api/me/listings, GET /api/categories, GET /api/admin/flags
// React frontend with category grid, listing cards, filter sidebar, post form, My Listings dashboard
```

> Pro tip: Seed your categories immediately after creating the tables. Open Drizzle Studio and manually insert your top-level categories first (parent_id = null), then subcategories referencing them. Without categories, no listings can be created.

**Expected result:** Project running with all four tables. The categories table is ready to be seeded.

### 2. Seed categories and add the recursive CTE for category browsing

The recursive CTE is the key query that makes 'browse Electronics' show all Phone, Tablet, and Laptop listings automatically. Without it, you'd need a separate query per subcategory level.

```
// Seed categories in Drizzle Studio or via an admin route:
// Top-level: Electronics (id:1), Vehicles (id:2), Housing (id:3), Services (id:4), Free (id:5)
// Under Electronics: Phones (id:6, parent:1), Computers (id:7, parent:1), Gaming (id:8, parent:1)
// Under Phones: iPhones (id:9, parent:6), Android Phones (id:10, parent:6)

// The recursive CTE query for GET /api/listings?category=electronics:
import { db } from '../db.js';
import { sql } from 'drizzle-orm';

export async function getListingsByCategory(categoryId) {
  // Find all descendant category IDs including the root
  const categoryResult = await db.execute(sql`
    WITH RECURSIVE cat_tree AS (
      SELECT id FROM categories WHERE id = ${categoryId}
      UNION ALL
      SELECT c.id FROM categories c
      JOIN cat_tree ct ON c.parent_id = ct.id
    )
    SELECT id FROM cat_tree
  `);

  const categoryIds = categoryResult.rows.map(r => r.id);
  if (categoryIds.length === 0) return [];

  // Fetch all active listings in any of these categories
  const listings = await db.execute(sql`
    SELECT l.*, c.name as category_name
    FROM listings l
    JOIN categories c ON l.category_id = c.id
    WHERE l.category_id = ANY(${categoryIds}::int[])
    AND l.status = 'active'
    AND (l.expires_at IS NULL OR l.expires_at > now())
    ORDER BY l.created_at DESC
    LIMIT 50
  `);

  return listings.rows;
}
```

> Pro tip: Index categories.parent_id to make the recursive CTE fast: CREATE INDEX idx_categories_parent ON categories(parent_id). Without this, the recursion does a full table scan on every level.

**Expected result:** GET /api/listings?category=1 (Electronics) returns listings from all subcategories — Phones, Computers, Gaming, iPhones, Android Phones — without separate requests per subcategory.

### 3. Add full-text search with combined price and condition filters

The search endpoint combines PostgreSQL's tsvector for text search with standard WHERE clauses for structured filters. A GIN index makes this fast even with thousands of listings.

```
import { db } from '../db.js';
import { sql } from 'drizzle-orm';

// First, add the GIN index (run once in Drizzle Studio or via a migration):
// CREATE INDEX listings_search ON listings USING GIN (
//   to_tsvector('english', title || ' ' || description)
// );

export async function searchListings(req, res) {
  const { q, category, minPrice, maxPrice, condition, location, page = 1, limit = 20 } = req.query;
  const offset = (parseInt(page) - 1) * parseInt(limit);

  const params = [];
  const conditions = ["l.status = 'active'", '(l.expires_at IS NULL OR l.expires_at > now())'];

  if (q) {
    params.push(q);
    conditions.push(`to_tsvector('english', l.title || ' ' || l.description) @@ plainto_tsquery('english', $${params.length})`);
  }
  if (category) {
    params.push(parseInt(category));
    conditions.push(`l.category_id = $${params.length}`);
  }
  if (minPrice) {
    params.push(parseInt(minPrice) * 100); // convert dollars to cents
    conditions.push(`l.price >= $${params.length}`);
  }
  if (maxPrice) {
    params.push(parseInt(maxPrice) * 100);
    conditions.push(`(l.price <= $${params.length} OR l.price IS NULL)`);
  }
  if (condition) {
    params.push(condition);
    conditions.push(`l.condition = $${params.length}`);
  }
  if (location) {
    params.push(`%${location}%`);
    conditions.push(`l.location ILIKE $${params.length}`);
  }

  const whereClause = conditions.join(' AND ');
  params.push(parseInt(limit), offset);
  const client = await db.$client.connect();
  const { rows } = await client.query(
    `SELECT l.*, c.name as category_name FROM listings l JOIN categories c ON l.category_id = c.id WHERE ${whereClause} ORDER BY l.created_at DESC LIMIT $${params.length - 1} OFFSET $${params.length}`,
    params
  );
  client.release();
  res.json({ data: rows, page: parseInt(page) });
}
```

> Pro tip: Use plainto_tsquery for user-facing search (it handles natural input like 'apple iphone 14') rather than to_tsquery (which requires syntax like 'apple & iphone & 14'). plainto_tsquery never throws a syntax error on unusual input.

**Expected result:** GET /api/listings?q=iphone&condition=like_new&minPrice=200&maxPrice=800 returns listings matching 'iphone' in like_new condition priced between $200 and $800.

### 4. Add listing expiration, renewal, and the moderation queue

Listings expire 30 days after posting. Sellers can renew for another 30 days. A Scheduled Deployment runs daily to mark expired listings. The moderation queue shows flagged listings for admin review.

```
import { db } from '../db.js';
import { listings, flags } from '../../shared/schema.js';
import { eq, lt, and, sql } from 'drizzle-orm';

// POST /api/listings — set expires_at to 30 days from now
export async function createListing(req, res) {
  const posterId = req.get('X-Replit-User-Id');
  if (!posterId) return res.status(401).json({ error: 'Not authenticated' });

  const expiresAt = new Date();
  expiresAt.setDate(expiresAt.getDate() + 30);

  const [listing] = await db.insert(listings).values({
    ...req.body,
    posterId,
    expiresAt,
    status: 'active',
    viewCount: 0,
  }).returning();

  res.status(201).json(listing);
}

// PATCH /api/listings/:id/renew — extend by 30 days
export async function renewListing(req, res) {
  const posterId = req.get('X-Replit-User-Id');
  const { id } = req.params;

  const [listing] = await db.select().from(listings).where(eq(listings.id, parseInt(id)));
  if (!listing || listing.posterId !== posterId) return res.status(403).json({ error: 'Not authorized' });

  const newExpiry = new Date();
  newExpiry.setDate(newExpiry.getDate() + 30);

  const [updated] = await db.update(listings)
    .set({ expiresAt: newExpiry, status: 'active' })
    .where(eq(listings.id, parseInt(id)))
    .returning();

  res.json(updated);
}

// Scheduled expiration job (run as a Replit Scheduled Deployment daily)
export async function expireListings() {
  const result = await db.update(listings)
    .set({ status: 'expired' })
    .where(and(eq(listings.status, 'active'), lt(listings.expiresAt, new Date())));
  console.log(`Expired listings updated: ${result.rowCount}`);
}

// GET /api/admin/flags — moderation queue
export async function getPendingFlags(req, res) {
  const adminId = req.get('X-Replit-User-Id');
  // TODO: check admin role in users table
  const pendingFlags = await db.execute(sql`
    SELECT f.*, l.title, l.description, l.poster_id
    FROM flags f JOIN listings l ON f.listing_id = l.id
    WHERE f.status = 'pending'
    ORDER BY f.created_at ASC
  `);
  res.json(pendingFlags.rows);
}
```

> Pro tip: Create a separate Scheduled Deployment in Replit pointing to a dedicated expiration route (GET /api/admin/expire) rather than using setInterval in the main server. Scheduled Deployments are more reliable and cost less than keeping a Reserved VM running just for daily cleanup.

**Expected result:** New listings have expires_at set 30 days in the future. After calling /renew, expires_at moves 30 days forward. The moderation queue returns all flags with status 'pending' and the associated listing details.

## Complete code example

File: `server/routes/listings.js`

```javascript
import { db } from '../db.js';
import { listings } from '../../shared/schema.js';
import { eq, and, lt } from 'drizzle-orm';

const LISTING_EXPIRY_DAYS = 30;

export async function createListing(req, res) {
  const posterId = req.get('X-Replit-User-Id');
  if (!posterId) return res.status(401).json({ error: 'Not authenticated' });

  const { categoryId, title, description, price, priceType, condition, location, images, contactMethod, contactEmail, contactPhone } = req.body;
  if (!categoryId || !title || !description || !location) {
    return res.status(400).json({ error: 'categoryId, title, description, and location are required' });
  }

  const expiresAt = new Date();
  expiresAt.setDate(expiresAt.getDate() + LISTING_EXPIRY_DAYS);

  const [listing] = await db.insert(listings).values({
    categoryId: parseInt(categoryId),
    title, description,
    price: price ? parseInt(price) : null,
    priceType: priceType || 'fixed',
    condition: condition || null,
    location, images: images || [],
    contactMethod: contactMethod || 'email',
    contactEmail: contactEmail || null,
    contactPhone: contactPhone || null,
    posterId,
    status: 'active',
    expiresAt,
    viewCount: 0,
  }).returning();

  res.status(201).json(listing);
}

export async function getListing(req, res) {
  const { id } = req.params;
  await db.execute(`UPDATE listings SET view_count = view_count + 1 WHERE id = ${parseInt(id)}`);
  const [listing] = await db.select().from(listings).where(eq(listings.id, parseInt(id)));
  if (!listing || listing.status === 'removed') return res.status(404).json({ error: 'Listing not found' });
  // Mask contact info based on contact_method
  if (listing.contactMethod === 'email') delete listing.contactPhone;
  if (listing.contactMethod === 'phone') delete listing.contactEmail;
  res.json(listing);
}
```

## Common mistakes

- **Not filtering expired listings from search results** — Without filtering, expired listings appear in search results, confusing users who try to contact sellers for listings that are no longer active. Fix: Add AND (l.expires_at IS NULL OR l.expires_at > now()) to all public listing queries. Also add AND l.status = 'active' to exclude sold and removed listings.
- **Querying only the selected category without its children** — A user browsing 'Electronics' expects to see phones, computers, and tablets — not an empty page because items are in subcategories. Fix: Use the recursive CTE shown in Step 2 to collect all descendant category IDs, then filter listings by category_id IN (all descendant IDs).
- **Exposing all contact details regardless of contact_method setting** — Sellers who chose 'email only' contact shouldn't have their phone number returned in the API response. Fix: In the GET /api/listings/:id route, delete listing.contactPhone if contactMethod is 'email', and delete listing.contactEmail if contactMethod is 'phone'. Only return the chosen contact method.

## Best practices

- Use the recursive CTE for category traversal — it's cleaner and more performant than multiple round-trip queries.
- Add a GIN index on listings for full-text search: CREATE INDEX listings_search ON listings USING GIN (to_tsvector('english', title || ' ' || description)).
- Store prices in cents (integer) and convert to dollars only for display.
- Use a Replit Scheduled Deployment for listing expiration — more reliable than setInterval in the main server.
- Mask contact info in API responses based on each listing's contact_method setting.
- Deploy the main app on Autoscale and the expiration job as a separate Scheduled Deployment.
- Implement idempotent listing renewal: if expires_at is still in the future, extend from the current expires_at, not from today.

## Frequently asked questions

### How do I stop spam listings from appearing?

Add a requires_approval boolean column to categories. Set it to true for categories prone to spam. When a listing is created in a requires_approval category, set status to 'pending_review' instead of 'active'. Admin sees it in the moderation queue and approves or rejects it.

### Can I add a map view to show listings by location?

Yes. Add latitude and longitude columns to listings. Geocode the location text when a listing is created (using OpenCage or Mapbox API with key in Replit Secrets). On the frontend, use react-leaflet to display a map with listing markers. Add a viewport bounding box filter to GET /api/listings for efficient map-based loading.

### How do listings expire automatically?

Create a Replit Scheduled Deployment pointing to GET /api/admin/expire-listings. This route runs UPDATE listings SET status = 'expired' WHERE status = 'active' AND expires_at < now(). The Scheduled Deployment runs it daily at midnight. This is more reliable than setInterval because Scheduled Deployments are managed by Replit's infrastructure.

### Do I need Replit Core for this build?

Yes. Replit Auth (used for posting listings and admin moderation) requires Replit Core or higher. The built-in PostgreSQL is also included in Core. The free tier doesn't include these features.

### How do I handle subcategory nesting deeper than 2 levels?

The recursive CTE works for any depth automatically. Electronics → Phones → iPhones → Refurbished iPhones is 3 levels — the CTE traverses all levels in one query. There's no code change needed to support deeper nesting.

### Can RapidDev help me build a custom classifieds platform?

Yes. RapidDev has built 600+ apps including classifieds and marketplace platforms with payment collection, escrow, and geo-based filtering. Contact us for a free consultation.

### What's the difference between status 'expired', 'sold', and 'removed'?

'expired' means the listing's 30-day window ended and the seller didn't renew. 'sold' means the seller marked it as sold. 'removed' means an admin removed it for policy violations. All three are hidden from public browsing but visible to the seller in their My Listings dashboard for reference.

---

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