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.
What you're building
Local classifieds marketplaces connect buyers and sellers in a community. Think Craigslist or Facebook Marketplace — people post items for sale with photos, others browse by category and location, and interested buyers contact sellers directly.
V0 generates the listing gallery, search interface, messaging system, and moderation tools from prompts. Supabase via the Connect panel provides the database with full-text search, Storage for images, and Auth for user accounts.
The architecture uses Next.js Server Components for the listing pages (fast, SEO-friendly), Supabase full-text search with tsvector for instant search without external services, Supabase Storage for image uploads, a messaging system with per-listing threads, and content flagging for community safety.
Final result
A local classifieds site where users post items with photos, browse by category and location, search with full-text matching, message sellers, and flag inappropriate content — with a moderation queue for administrators.
Tech stack
Prerequisites
- 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)
Build steps
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.
1// Paste this prompt into V0's AI chat:2// Build a classifieds site with Supabase. Create these tables:3// 1. categories: id (uuid PK), name (text), slug (text unique), icon (text), parent_id (uuid FK self-reference)4// 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)5// 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)6// 4. flags: id (uuid PK), listing_id (uuid FK), reporter_id (uuid FK), reason (text), created_at (timestamptz)7// Add a full-text search column on listings: search_vector tsvector GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || description)) STORED8// Add a GIN index on search_vector.9// 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.
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.
1// Paste this prompt into V0's AI chat:2// Build a classifieds browse page at app/listings/page.tsx.3// Requirements:4// - Search Input at top with Search icon that uses full-text search via Supabase to_tsquery5// - Filter sidebar (collapsible Sheet on mobile) with:6// - Select for category7// - RadioGroup for condition (new, like new, good, fair, poor)8// - Price range with two Input fields (min, max)9// - Input for location (city or state)10// - Responsive Card grid (2 cols mobile, 3 desktop) with:11// - Listing image (first from images array) using next/image12// - Title, price (formatted as currency), condition Badge, location text13// - Relative timestamp (e.g. "2 hours ago")14// - Sort Select: newest, price low-high, price high-low15// - Pagination at bottom16// - Use Server Components for initial data fetch with searchParams for filters17// - Skeleton loading statesExpected result: The browse page shows listings in a filterable grid with full-text search, category filters, price range, and condition selection.
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.
1import { createClient } from '@supabase/supabase-js'2import { notFound } from 'next/navigation'3import type { Metadata } from 'next'4import { ListingDetail } from '@/components/listing-detail'56const supabase = createClient(7 process.env.SUPABASE_URL!,8 process.env.SUPABASE_SERVICE_ROLE_KEY!9)1011export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {12 const { id } = await params13 const { data: listing } = await supabase.from('listings').select('title, description, price').eq('id', id).single()14 if (!listing) return {}15 return {16 title: `${listing.title} - $${listing.price}`,17 description: listing.description?.substring(0, 155),18 }19}2021export default async function ListingPage({ params }: { params: Promise<{ id: string }> }) {22 const { id } = await params23 const { data: listing } = await supabase24 .from('listings')25 .select('*, profiles:seller_id(full_name, avatar_url)')26 .eq('id', id)27 .eq('status', 'active')28 .single()2930 if (!listing) notFound()31 return <ListingDetail listing={listing} />32}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.
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.
1// Paste this prompt into V0's AI chat:2// Build a listing submission form at app/listings/new/page.tsx ('use client').3// Requirements:4// - Input for title5// - Textarea for description6// - Input for price with currency formatting7// - Select for category (fetched from categories table)8// - RadioGroup for condition: new, like new, good, fair, poor9// - Image upload area: drag-and-drop multiple images to Supabase Storage 'listings' public bucket10// - Show image previews, allow reordering, max 8 images, max 5MB each11// - Accept jpg, png, webp12// - Input for location city and Select for state13// - Submit Button that calls a Server Action with all fields including image URLs14// - Form validation with zod: title required, price > 0, at least one image15// - Use shadcn/ui Card for form container, Input, Textarea, Select, RadioGroup, ButtonExpected result: The submission form collects all listing details with image uploads to Supabase Storage. Validation ensures required fields and at least one image.
Add the messaging inbox and content moderation
Build the buyer-seller messaging system and the admin moderation queue for flagged listings.
1'use server'23import { createClient } from '@supabase/supabase-js'4import { revalidatePath } from 'next/cache'56const supabase = createClient(7 process.env.SUPABASE_URL!,8 process.env.SUPABASE_SERVICE_ROLE_KEY!9)1011export async function sendMessage(listingId: string, senderId: string, receiverId: string, content: string) {12 const { error } = await supabase.from('messages').insert({13 listing_id: listingId,14 sender_id: senderId,15 receiver_id: receiverId,16 content,17 })18 if (error) throw new Error(error.message)19 revalidatePath('/messages')20}2122export async function flagListing(listingId: string, reporterId: string, reason: string) {23 await supabase.from('flags').insert({ listing_id: listingId, reporter_id: reporterId, reason })24 const { count } = await supabase.from('flags').select('*', { count: 'exact', head: true }).eq('listing_id', listingId)25 if ((count ?? 0) >= 3) {26 await supabase.from('listings').update({ status: 'flagged' }).eq('id', listingId)27 }28 revalidatePath(`/listings/${listingId}`)29}3031export async function moderateListing(listingId: string, action: 'approve' | 'remove') {32 const status = action === 'approve' ? 'active' : 'expired'33 await supabase.from('listings').update({ status }).eq('id', listingId)34 if (action === 'approve') {35 await supabase.from('flags').delete().eq('listing_id', listingId)36 }37 revalidatePath('/admin')38}Expected result: Buyers can message sellers about listings. Listings with 3+ flags are auto-flagged. Admins can approve or remove flagged listings.
Complete code
1'use server'23import { createClient } from '@supabase/supabase-js'4import { revalidatePath } from 'next/cache'5import { redirect } from 'next/navigation'67const supabase = createClient(8 process.env.SUPABASE_URL!,9 process.env.SUPABASE_SERVICE_ROLE_KEY!10)1112export async function createListing(formData: FormData) {13 const title = formData.get('title') as string14 const description = formData.get('description') as string15 const price = parseFloat(formData.get('price') as string)16 const category = formData.get('category') as string17 const condition = formData.get('condition') as string18 const images = JSON.parse(formData.get('images') as string)19 const city = formData.get('city') as string20 const state = formData.get('state') as string21 const sellerId = formData.get('seller_id') as string2223 const expiresAt = new Date()24 expiresAt.setDate(expiresAt.getDate() + 30)2526 const { error } = await supabase.from('listings').insert({27 seller_id: sellerId,28 title,29 description,30 price,31 category,32 condition,33 images,34 location_city: city,35 location_state: state,36 status: 'active',37 expires_at: expiresAt.toISOString(),38 })3940 if (error) throw new Error(error.message)41 revalidatePath('/listings')42 redirect('/listings')43}4445export async function sendMessage(46 listingId: string,47 senderId: string,48 receiverId: string,49 content: string50) {51 const { error } = await supabase.from('messages').insert({52 listing_id: listingId,53 sender_id: senderId,54 receiver_id: receiverId,55 content,56 })57 if (error) throw new Error(error.message)58 revalidatePath('/messages')59}6061export async function flagListing(62 listingId: string,63 reporterId: string,64 reason: string65) {66 await supabase.from('flags').insert({67 listing_id: listingId,68 reporter_id: reporterId,69 reason,70 })71 revalidatePath(`/listings/${listingId}`)72}Customization ideas
Add location-based search with PostGIS
Enable the PostGIS extension in Supabase and add a geography column to listings. Create an RPC function for proximity search with ST_DWithin to find listings within a radius.
Add favorites/watchlist
Add a favorites table linking users to listings. Show a heart toggle on each Card and create a Favorites page showing saved items with alerts when prices drop.
Add premium listings
Add a featured field to listings with Stripe payment for premium placement. Featured listings appear at the top of search results with a special Badge.
Add email notifications
Send sellers email notifications when they receive new messages, and send saved search alerts when new listings match a user's search criteria.
Common pitfalls
Pitfall: Using LIKE queries instead of full-text search
How to avoid: 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.
Pitfall: Not setting image size limits on upload
How to avoid: 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.
Pitfall: Allowing sellers to see all messages in the system
How to avoid: 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
AI prompts to try
Copy these prompts to build this project faster.
I'm building a local classifieds site with Next.js App Router and Supabase. I need listing management with image uploads, full-text search with tsvector, category filtering, buyer-seller messaging, and content moderation with community flagging. Help me design the full-text search setup and the messaging schema.
Build a Supabase full-text search component for a classifieds site. The search should: use a tsvector generated column on listings (title + description), query with textSearch() or to_tsquery via RPC, combine with category and condition filters in a single query, support sort by relevance (ts_rank), and display results in a responsive Card grid with highlighted matching terms.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation