Build a filterable, categorized directory service with V0 using Next.js Server Components, Supabase full-text search, and SEO-friendly listing pages. You'll create a searchable listing grid, submission forms, review system, and admin approval workflow — all in about 30-60 minutes without touching a terminal.
What you're building
Directories are one of the most useful web apps for communities, industries, and local markets. Whether you are listing businesses, tools, professionals, or resources, a well-structured directory helps users find exactly what they need through search and category filters.
V0 accelerates directory development by generating Next.js Server Components from natural language prompts. You describe your listing layout, and V0 creates the pages, data fetching logic, and UI components. Pair that with Supabase via the Connect panel for instant database setup, and you have a searchable directory deployed on Vercel in under an hour.
The architecture uses Next.js App Router with Server Components for all data fetching (zero client-side JavaScript for listing pages), Supabase PostgreSQL with tsvector for full-text search, Server Actions for form submissions, and shadcn/ui for a clean, accessible interface.
Final result
A fully functional directory service with categorized listings, full-text search, star ratings, a public submission form, and an admin approval workflow.
Tech stack
Prerequisites
- A V0 account (free tier works for this project)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A list of categories for your directory (e.g., restaurants, agencies, SaaS tools)
- Basic understanding of what you want to list (names, descriptions, websites)
Build steps
Set up the project and database schema with V0
Open V0 and start a new project. Use the Connect panel to add Supabase — this auto-provisions your database URL and keys into the Vars tab. Then prompt V0 to create the schema for categories, listings, and reviews.
1// Paste this prompt into V0's AI chat:2// Build a directory service. Create a Supabase schema with these tables:3// 1. categories: id (uuid PK default gen_random_uuid()), name (text), slug (text unique), icon (text)4// 2. listings: id (uuid PK), category_id (uuid FK to categories), title (text), slug (text unique), description (text), website_url (text), logo_url (text), location (text), tags (text[]), featured (boolean default false), status (text default 'pending'), submitted_by (uuid FK to auth.users), created_at (timestamptz default now())5// 3. reviews: id (uuid PK), listing_id (uuid FK to listings), user_id (uuid FK to auth.users), rating (int check between 1 and 5), comment (text), created_at (timestamptz default now())6// Add a generated tsvector column on listings for full-text search across title, description, and tags.7// Add RLS policies so anyone can read approved listings, but only authenticated users can submit.Pro tip: Use V0's prompt queuing — queue the schema prompt first, then queue the homepage layout prompt right after. V0 processes up to 10 queued prompts sequentially.
Expected result: Supabase is connected via the Connect panel, all three tables are created with RLS policies, and the tsvector column is ready for search.
Build the homepage with featured listings and category grid
Prompt V0 to create the main directory homepage. This is a Server Component that fetches featured listings and all categories directly from Supabase — no client-side JavaScript needed for the initial render.
1import { createClient } from '@/lib/supabase/server'2import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'3import { Badge } from '@/components/ui/badge'4import { Input } from '@/components/ui/input'5import Link from 'next/link'67export default async function HomePage() {8 const supabase = await createClient()910 const { data: categories } = await supabase11 .from('categories')12 .select('*')13 .order('name')1415 const { data: featured } = await supabase16 .from('listings')17 .select('*, categories(name, slug)')18 .eq('status', 'approved')19 .eq('featured', true)20 .limit(6)2122 return (23 <div className="container mx-auto py-8">24 <h1 className="text-4xl font-bold mb-4">Find the best tools and services</h1>25 <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-12">26 {categories?.map((cat) => (27 <Link key={cat.id} href={`/category/${cat.slug}`}>28 <Card className="hover:border-primary transition-colors">29 <CardContent className="p-4 text-center">30 <span className="text-2xl">{cat.icon}</span>31 <p className="mt-2 font-medium">{cat.name}</p>32 </CardContent>33 </Card>34 </Link>35 ))}36 </div>37 <h2 className="text-2xl font-semibold mb-4">Featured listings</h2>38 <div className="grid grid-cols-1 md:grid-cols-3 gap-6">39 {featured?.map((listing) => (40 <Link key={listing.id} href={`/listing/${listing.slug}`}>41 <Card>42 <CardHeader>43 <CardTitle>{listing.title}</CardTitle>44 </CardHeader>45 <CardContent>46 <p className="text-muted-foreground line-clamp-2">{listing.description}</p>47 <div className="flex gap-2 mt-3">48 {listing.tags?.map((tag: string) => (49 <Badge key={tag} variant="secondary">{tag}</Badge>50 ))}51 </div>52 </CardContent>53 </Card>54 </Link>55 ))}56 </div>57 </div>58 )59}Expected result: The homepage displays a category grid at the top and a featured listings section below, all server-rendered for fast load times and SEO.
Create the filtered category page with full-text search
Build the category listing page that filters by category slug and supports full-text search. The tsvector column enables instant search without any external service. All data fetching runs server-side.
1import { createClient } from '@/lib/supabase/server'2import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'3import { Badge } from '@/components/ui/badge'4import { Input } from '@/components/ui/input'5import Link from 'next/link'67export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {8 const { slug } = await params9 return {10 title: `${slug.replace(/-/g, ' ')} Directory | Find Top Providers`,11 description: `Browse and compare the best ${slug.replace(/-/g, ' ')} options. Searchable directory with ratings and reviews.`,12 }13}1415export default async function CategoryPage({16 params,17 searchParams,18}: {19 params: Promise<{ slug: string }>20 searchParams: Promise<{ q?: string }>21}) {22 const { slug } = await params23 const { q } = await searchParams24 const supabase = await createClient()2526 let query = supabase27 .from('listings')28 .select('*, categories!inner(name, slug)')29 .eq('categories.slug', slug)30 .eq('status', 'approved')3132 if (q) {33 query = query.textSearch('search_vector', q, { type: 'websearch' })34 }3536 const { data: listings } = await query.order('featured', { ascending: false })3738 return (39 <div className="container mx-auto py-8">40 <h1 className="text-3xl font-bold mb-6 capitalize">{slug.replace(/-/g, ' ')}</h1>41 <form className="mb-6">42 <Input name="q" placeholder="Search listings..." defaultValue={q} className="max-w-md" />43 </form>44 <div className="grid grid-cols-1 md:grid-cols-3 gap-6">45 {listings?.map((listing) => (46 <Link key={listing.id} href={`/listing/${listing.slug}`}>47 <Card>48 <CardHeader>49 <CardTitle className="flex items-center gap-2">50 {listing.title}51 {listing.featured && <Badge>Featured</Badge>}52 </CardTitle>53 </CardHeader>54 <CardContent>55 <p className="text-muted-foreground line-clamp-2">{listing.description}</p>56 </CardContent>57 </Card>58 </Link>59 ))}60 </div>61 </div>62 )63}Pro tip: Use Design Mode (Option+D) to visually adjust the listing card spacing, typography, and grid layout without spending any V0 credits.
Expected result: Visiting /category/saas-tools shows only listings in that category. Typing in the search box filters results using PostgreSQL full-text search.
Build the listing detail page with reviews
Create the individual listing page that shows full details, average rating, and user reviews. This page uses generateMetadata for SEO and a client component for the review submission form.
1// Paste this prompt into V0's AI chat:2// Build a listing detail page at app/listing/[slug]/page.tsx.3// Requirements:4// - Server Component that fetches the listing by slug with its reviews and average rating5// - Use generateMetadata to set title and description from the listing data6// - Display: listing title as h1, description, website link (Button with external link), location with map pin icon, tags as Badge components7// - Show average rating with filled/empty stars and total review count8// - List all reviews in Card components with Avatar, star rating, comment text, and date9// - Add a 'use client' ReviewForm component below with RadioGroup for star rating (1-5), Textarea for comment, and Button to submit10// - The form submits via a Server Action that validates with Zod (rating 1-5 required, comment 10+ chars) and inserts into the reviews table11// - Show a "Submit a listing" link to /submit in the sidebarExpected result: Each listing page shows full details, an average star rating, all reviews, and a form to submit new reviews. The page has proper meta tags for SEO.
Create the submission form and admin approval panel
Build the public submission form where anyone can suggest a new listing, and an admin page to approve or reject pending submissions. The form uses Zod validation through a Server Action.
1// Paste this prompt into V0's AI chat:2// Build two pages:3// 1. app/submit/page.tsx — a 'use client' listing submission form with:4// - Input for title, website URL, location5// - Textarea for description6// - Select dropdown for category (fetched from categories table)7// - Input for comma-separated tags8// - Button to submit via Server Action with Zod validation (title required, valid URL, description 20+ chars)9// - Show success message after submission: "Your listing is pending review"10// 2. app/admin/page.tsx — admin panel showing pending listings in a shadcn/ui Table with:11// - Columns: title, category, submitted date, actions12// - Two Button actions per row: Approve (green) and Reject (red) using Server Actions13// - Approve sets status to 'approved', Reject sets to 'rejected'14// - Badge for status column15// - Protect with a simple auth check (require authenticated user with admin role)Pro tip: All environment variables for this project are server-side only — no NEXT_PUBLIC_ prefix needed since all data fetching happens in Server Components and Server Actions.
Expected result: Users can submit new listings via the form. Admins see pending submissions in a table and can approve or reject them with one click.
Complete code
1import { createClient } from '@/lib/supabase/server'2import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'3import { Badge } from '@/components/ui/badge'4import { Input } from '@/components/ui/input'5import Link from 'next/link'6import { Metadata } from 'next'78type Props = {9 params: Promise<{ slug: string }>10 searchParams: Promise<{ q?: string }>11}1213export async function generateMetadata({ params }: Props): Promise<Metadata> {14 const { slug } = await params15 const title = slug.replace(/-/g, ' ')16 return {17 title: `${title} Directory | Browse Top Listings`,18 description: `Explore the best ${title} options with reviews and ratings.`,19 }20}2122export default async function CategoryPage({ params, searchParams }: Props) {23 const { slug } = await params24 const { q } = await searchParams25 const supabase = await createClient()2627 let query = supabase28 .from('listings')29 .select('id, title, slug, description, tags, featured, categories!inner(name, slug)')30 .eq('categories.slug', slug)31 .eq('status', 'approved')3233 if (q) {34 query = query.textSearch('search_vector', q, { type: 'websearch' })35 }3637 const { data: listings } = await query.order('featured', { ascending: false })3839 return (40 <div className="container mx-auto py-8">41 <h1 className="text-3xl font-bold mb-6 capitalize">42 {slug.replace(/-/g, ' ')}43 </h1>44 <form className="mb-6">45 <Input46 name="q"47 placeholder="Search listings..."48 defaultValue={q}49 className="max-w-md"50 />51 </form>52 <div className="grid grid-cols-1 md:grid-cols-3 gap-6">53 {listings?.map((listing) => (54 <Link key={listing.id} href={`/listing/${listing.slug}`}>55 <Card className="hover:shadow-md transition-shadow">56 <CardHeader>57 <CardTitle className="flex items-center gap-2">58 {listing.title}59 {listing.featured && <Badge>Featured</Badge>}60 </CardTitle>61 </CardHeader>62 <CardContent>63 <p className="text-muted-foreground line-clamp-2">64 {listing.description}65 </p>66 <div className="flex flex-wrap gap-1 mt-3">67 {listing.tags?.map((tag: string) => (68 <Badge key={tag} variant="secondary">{tag}</Badge>69 ))}70 </div>71 </CardContent>72 </Card>73 </Link>74 ))}75 </div>76 </div>77 )78}Customization ideas
Add map view for location-based listings
Integrate Mapbox via react-map-gl to show listings on an interactive map alongside the grid view, using dynamic import with ssr: false.
Add claimed listings for business owners
Let business owners claim their listing by verifying ownership via email, then allow them to edit details and respond to reviews.
Add featured listing payments
Integrate Stripe via Vercel Marketplace to let listing owners pay for featured placement, using a checkout session and webhook to toggle the featured flag.
Add email alerts for new listings
Use Resend to send email notifications to subscribers when new listings are approved in their followed categories.
Common pitfalls
Pitfall: Using client-side data fetching for listing pages instead of Server Components
How to avoid: Keep listing pages as Server Components — fetch data directly with await in the component body. Only add 'use client' for interactive forms like the review submission.
Pitfall: Forgetting to add a tsvector index for full-text search
How to avoid: Create a GIN index on the generated tsvector column: CREATE INDEX listings_search_idx ON listings USING GIN (search_vector).
Pitfall: Not setting RLS policies on the listings table
How to avoid: Add RLS policies: allow SELECT where status = 'approved' for anonymous users, allow INSERT for authenticated users, and allow UPDATE only for admin role.
Best practices
- Use Server Components for all listing and category pages — they render on the server with zero client-side JavaScript, which improves SEO and performance.
- Add generateMetadata to every listing and category page for dynamic SEO meta tags based on the content.
- Use Design Mode (Option+D) to visually adjust card layouts, spacing, and colors without spending V0 credits.
- Create a composite index on (category_id, status, featured) for fast filtered queries on category pages.
- Validate all form submissions with Zod in Server Actions — never trust client-side validation alone.
- Use Badge components from shadcn/ui consistently for tags, categories, and status indicators across all pages.
- Keep all Supabase keys server-side (no NEXT_PUBLIC_ prefix) since all data fetching runs in Server Components.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a directory website with Next.js App Router and Supabase. I need to implement full-text search across listing titles, descriptions, and tags using PostgreSQL tsvector. Show me how to create the generated column, GIN index, and query it from a Server Component using the Supabase client. The directory has categories, listings with tags as text arrays, and reviews with star ratings.
Build the admin approval workflow for a directory service. Create app/admin/page.tsx as a Server Component that fetches all listings with status 'pending' from Supabase. Display them in a shadcn/ui Table with columns for title, category, submitted date, and actions. Add Approve and Reject buttons that call Server Actions to update the listing status. Include an AlertDialog confirmation before rejection. Protect the page with middleware that checks for an admin role claim.
Frequently asked questions
Can I build a directory service with the free V0 plan?
Yes. The free V0 plan gives you enough credits to build a complete directory. Since most pages are Server Components, V0 generates them efficiently. Use Design Mode (Option+D) for visual tweaks at no credit cost.
How does full-text search work without a third-party service?
Supabase runs on PostgreSQL, which has built-in full-text search via tsvector columns and to_tsquery. You create a generated column that combines title, description, and tags into a search vector, add a GIN index for speed, and query it using Supabase's textSearch method.
How do I deploy my directory to a live URL?
Click Share in the top-right of V0, then the Publish tab, and hit Publish to Production. Your directory is live on a Vercel URL in about 30-60 seconds. For a custom domain, connect it in the Vercel Dashboard after publishing.
Can I add paid featured listings later?
Yes. Add Stripe via the Vercel Marketplace in V0's Connect panel. Create a checkout session that sets the listing's featured flag to true on payment confirmation via a Stripe webhook handler.
How do I prevent spam submissions?
The approval workflow handles this — all new submissions have a 'pending' status and only appear publicly after admin approval. You can also add rate limiting per user and email verification for submitters.
Can RapidDev help build a custom directory service?
Yes. RapidDev has built 600+ apps including complex directory platforms with map integrations, payment systems, and advanced search. Book a free consultation to scope your project and get a custom build plan.
Do I need the NEXT_PUBLIC_ prefix for my Supabase keys?
No. Since all data fetching happens in Server Components and Server Actions, your Supabase keys stay server-side. Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in the Vars tab without any prefix.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation