# How to Build Classifieds with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Premium or higher
- Last updated: April 2026

## TL;DR

Build a local classifieds site with V0 using Next.js and Supabase. You'll get item listings with drag-and-drop multi-image uploads, category and location filtering, buyer-seller messaging, content flagging, and full-text search — all in about 1-2 hours without any local setup.

## Before you start

- A V0 account (Premium plan recommended for multi-feature builds)
- A Supabase project (free tier works — connect via V0's Connect panel)
- Basic understanding of classifieds marketplace concepts (listings, categories, messaging)

## Step-by-step guide

### 1. Set up the database schema with listings, categories, and messaging

Create a new V0 project, connect Supabase, and create the tables for listings with full-text search, hierarchical categories, messaging, and content flags.

```
// Paste this prompt into V0's AI chat:
// Build a classifieds site with Supabase. Create these tables:
// 1. categories: id (uuid PK), name (text), slug (text unique), icon (text), parent_id (uuid FK self-reference)
// 2. listings: id (uuid PK), seller_id (uuid FK to auth.users), title (text), description (text), price (numeric), category (text), condition (text CHECK in 'new','like_new','good','fair','poor'), images (text[]), location_city (text), location_state (text), status (text CHECK in 'active','sold','expired','flagged'), expires_at (timestamptz), created_at (timestamptz)
// 3. messages: id (uuid PK), listing_id (uuid FK), sender_id (uuid FK), receiver_id (uuid FK), content (text), is_read (boolean default false), created_at (timestamptz)
// 4. flags: id (uuid PK), listing_id (uuid FK), reporter_id (uuid FK), reason (text), created_at (timestamptz)
// Add a full-text search column on listings: search_vector tsvector GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || description)) STORED
// Add a GIN index on search_vector.
// Add RLS policies.
```

> Pro tip: The generated tsvector column with a GIN index gives you fast full-text search without any external search service. Supabase handles it natively in PostgreSQL.

**Expected result:** Supabase is connected with all tables created, full-text search configured with a GIN index, and RLS policies applied.

### 2. Build the listing gallery with search and filters

Create the main browse page with a responsive Card grid, search bar, and filters for category, condition, price range, and location. Use Server Components for SEO-friendly data fetching.

```
// Paste this prompt into V0's AI chat:
// Build a classifieds browse page at app/listings/page.tsx.
// Requirements:
// - Search Input at top with Search icon that uses full-text search via Supabase to_tsquery
// - Filter sidebar (collapsible Sheet on mobile) with:
//   - Select for category
//   - RadioGroup for condition (new, like new, good, fair, poor)
//   - Price range with two Input fields (min, max)
//   - Input for location (city or state)
// - Responsive Card grid (2 cols mobile, 3 desktop) with:
//   - Listing image (first from images array) using next/image
//   - Title, price (formatted as currency), condition Badge, location text
//   - Relative timestamp (e.g. "2 hours ago")
// - Sort Select: newest, price low-high, price high-low
// - Pagination at bottom
// - Use Server Components for initial data fetch with searchParams for filters
// - Skeleton loading states
```

**Expected result:** The browse page shows listings in a filterable grid with full-text search, category filters, price range, and condition selection.

### 3. Create the listing detail page with image carousel and contact form

Build the individual listing page with an image Carousel, seller info, and a contact form that starts a messaging thread between the buyer and seller.

```
import { createClient } from '@supabase/supabase-js'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import { ListingDetail } from '@/components/listing-detail'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
  const { id } = await params
  const { data: listing } = await supabase.from('listings').select('title, description, price').eq('id', id).single()
  if (!listing) return {}
  return {
    title: `${listing.title} - $${listing.price}`,
    description: listing.description?.substring(0, 155),
  }
}

export default async function ListingPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const { data: listing } = await supabase
    .from('listings')
    .select('*, profiles:seller_id(full_name, avatar_url)')
    .eq('id', id)
    .eq('status', 'active')
    .single()

  if (!listing) notFound()
  return <ListingDetail listing={listing} />
}
```

> Pro tip: Use V0's image upload pattern — generate a Supabase Storage upload component with drag-and-drop that stores images in a public bucket and returns URLs for the images array column.

**Expected result:** The listing detail page shows an image Carousel, seller info with Avatar, listing details, and a contact seller Dialog that starts a message thread.

### 4. Build the listing submission form with image uploads

Create the 'post an item' form where sellers can upload photos, enter details, select category and condition, and set a price. Images are uploaded to Supabase Storage.

```
// Paste this prompt into V0's AI chat:
// Build a listing submission form at app/listings/new/page.tsx ('use client').
// Requirements:
// - Input for title
// - Textarea for description
// - Input for price with currency formatting
// - Select for category (fetched from categories table)
// - RadioGroup for condition: new, like new, good, fair, poor
// - Image upload area: drag-and-drop multiple images to Supabase Storage 'listings' public bucket
//   - Show image previews, allow reordering, max 8 images, max 5MB each
//   - Accept jpg, png, webp
// - Input for location city and Select for state
// - Submit Button that calls a Server Action with all fields including image URLs
// - Form validation with zod: title required, price > 0, at least one image
// - Use shadcn/ui Card for form container, Input, Textarea, Select, RadioGroup, Button
```

**Expected result:** The submission form collects all listing details with image uploads to Supabase Storage. Validation ensures required fields and at least one image.

### 5. Add the messaging inbox and content moderation

Build the buyer-seller messaging system and the admin moderation queue for flagged listings.

```
'use server'

import { createClient } from '@supabase/supabase-js'
import { revalidatePath } from 'next/cache'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function sendMessage(listingId: string, senderId: string, receiverId: string, content: string) {
  const { error } = await supabase.from('messages').insert({
    listing_id: listingId,
    sender_id: senderId,
    receiver_id: receiverId,
    content,
  })
  if (error) throw new Error(error.message)
  revalidatePath('/messages')
}

export async function flagListing(listingId: string, reporterId: string, reason: string) {
  await supabase.from('flags').insert({ listing_id: listingId, reporter_id: reporterId, reason })
  const { count } = await supabase.from('flags').select('*', { count: 'exact', head: true }).eq('listing_id', listingId)
  if ((count ?? 0) >= 3) {
    await supabase.from('listings').update({ status: 'flagged' }).eq('id', listingId)
  }
  revalidatePath(`/listings/${listingId}`)
}

export async function moderateListing(listingId: string, action: 'approve' | 'remove') {
  const status = action === 'approve' ? 'active' : 'expired'
  await supabase.from('listings').update({ status }).eq('id', listingId)
  if (action === 'approve') {
    await supabase.from('flags').delete().eq('listing_id', listingId)
  }
  revalidatePath('/admin')
}
```

**Expected result:** Buyers can message sellers about listings. Listings with 3+ flags are auto-flagged. Admins can approve or remove flagged listings.

## Complete code example

File: `app/actions/classifieds.ts`

```typescript
'use server'

import { createClient } from '@supabase/supabase-js'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function createListing(formData: FormData) {
  const title = formData.get('title') as string
  const description = formData.get('description') as string
  const price = parseFloat(formData.get('price') as string)
  const category = formData.get('category') as string
  const condition = formData.get('condition') as string
  const images = JSON.parse(formData.get('images') as string)
  const city = formData.get('city') as string
  const state = formData.get('state') as string
  const sellerId = formData.get('seller_id') as string

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

  const { error } = await supabase.from('listings').insert({
    seller_id: sellerId,
    title,
    description,
    price,
    category,
    condition,
    images,
    location_city: city,
    location_state: state,
    status: 'active',
    expires_at: expiresAt.toISOString(),
  })

  if (error) throw new Error(error.message)
  revalidatePath('/listings')
  redirect('/listings')
}

export async function sendMessage(
  listingId: string,
  senderId: string,
  receiverId: string,
  content: string
) {
  const { error } = await supabase.from('messages').insert({
    listing_id: listingId,
    sender_id: senderId,
    receiver_id: receiverId,
    content,
  })
  if (error) throw new Error(error.message)
  revalidatePath('/messages')
}

export async function flagListing(
  listingId: string,
  reporterId: string,
  reason: string
) {
  await supabase.from('flags').insert({
    listing_id: listingId,
    reporter_id: reporterId,
    reason,
  })
  revalidatePath(`/listings/${listingId}`)
}
```

## Common mistakes

- **Using LIKE queries instead of full-text search** — LIKE '%keyword%' scans every row and cannot use indexes effectively. As your listings grow, search becomes extremely slow. Fix: Use PostgreSQL full-text search with a generated tsvector column and GIN index. Query with to_tsquery for fast, indexed search across title and description.
- **Not setting image size limits on upload** — Without limits, users can upload massive images that slow down page loads and fill your Supabase Storage quota quickly. Fix: Set a 5MB max file size limit on the upload component. Use Supabase Storage transformations for generating thumbnails to serve smaller images in listing Cards.
- **Allowing sellers to see all messages in the system** — Without proper RLS, a seller could potentially query and read messages from other users' listing conversations. Fix: Set RLS policies on messages so users can only read messages where they are either the sender_id or receiver_id. Test with different user accounts.

## Best practices

- Use PostgreSQL full-text search with generated tsvector column and GIN index for fast listing search without external services
- Use Supabase Storage public bucket for listing images with 5MB upload limit and accepted formats (jpg, png, webp)
- Use Server Components for the browse page to make listings SEO-friendly and server-rendered
- Add generateMetadata to listing detail pages for dynamic title and description in search results
- Use Design Mode (Option+D) to adjust listing Card layout, image aspect ratios, and filter panel styling without credits
- Auto-flag listings with 3+ community reports for moderation review to maintain platform quality
- Set 30-day expiry on listings with a status check that hides expired listings from search results
- Use RLS policies on messages so users only see conversations they are part of

## Frequently asked questions

### How does the full-text search work without external services?

PostgreSQL has built-in full-text search. A generated tsvector column automatically creates a searchable index from the title and description. A GIN index makes queries fast. Supabase exposes this through the textSearch filter or a custom RPC function with to_tsquery.

### How do I handle image uploads for listings?

Use Supabase Storage with a public bucket. Create a drag-and-drop upload component that stores images and returns public URLs. Save the URL array in the listings.images column. Set a 5MB max size and accept only jpg, png, and webp.

### What V0 plan do I need for a classifieds site?

V0 Premium is recommended because the classifieds site needs multiple pages (browse, detail, post, messages, moderation), image uploads, and full-text search configuration.

### How does content moderation work?

Users can flag listings with a reason. When a listing receives 3 or more flags, its status is automatically changed to flagged. Administrators see flagged listings in a moderation queue and can approve or remove them.

### How do I deploy the classifieds site?

Click Share then Publish to Production in V0 for instant Vercel deployment. Listing pages are server-rendered for SEO. Supabase Storage serves images via CDN.

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

Yes. RapidDev has built 600+ apps including marketplace platforms with geolocation search, payment escrow, and automated moderation. Book a free consultation to discuss your classifieds requirements.

---

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