# How to Build Directory service with V0

- Tool: v0
- Difficulty: Beginner
- Compatibility: V0 Free
- Last updated: April 2026

## TL;DR

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.

## Before you start

- 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)

## Step-by-step guide

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

```
// Paste this prompt into V0's AI chat:
// Build a directory service. Create a Supabase schema with these tables:
// 1. categories: id (uuid PK default gen_random_uuid()), name (text), slug (text unique), icon (text)
// 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())
// 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())
// Add a generated tsvector column on listings for full-text search across title, description, and tags.
// 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.

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

```
import { createClient } from '@/lib/supabase/server'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import Link from 'next/link'

export default async function HomePage() {
  const supabase = await createClient()

  const { data: categories } = await supabase
    .from('categories')
    .select('*')
    .order('name')

  const { data: featured } = await supabase
    .from('listings')
    .select('*, categories(name, slug)')
    .eq('status', 'approved')
    .eq('featured', true)
    .limit(6)

  return (
    <div className="container mx-auto py-8">
      <h1 className="text-4xl font-bold mb-4">Find the best tools and services</h1>
      <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-12">
        {categories?.map((cat) => (
          <Link key={cat.id} href={`/category/${cat.slug}`}>
            <Card className="hover:border-primary transition-colors">
              <CardContent className="p-4 text-center">
                <span className="text-2xl">{cat.icon}</span>
                <p className="mt-2 font-medium">{cat.name}</p>
              </CardContent>
            </Card>
          </Link>
        ))}
      </div>
      <h2 className="text-2xl font-semibold mb-4">Featured listings</h2>
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        {featured?.map((listing) => (
          <Link key={listing.id} href={`/listing/${listing.slug}`}>
            <Card>
              <CardHeader>
                <CardTitle>{listing.title}</CardTitle>
              </CardHeader>
              <CardContent>
                <p className="text-muted-foreground line-clamp-2">{listing.description}</p>
                <div className="flex gap-2 mt-3">
                  {listing.tags?.map((tag: string) => (
                    <Badge key={tag} variant="secondary">{tag}</Badge>
                  ))}
                </div>
              </CardContent>
            </Card>
          </Link>
        ))}
      </div>
    </div>
  )
}
```

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

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

```
import { createClient } from '@/lib/supabase/server'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import Link from 'next/link'

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  return {
    title: `${slug.replace(/-/g, ' ')} Directory | Find Top Providers`,
    description: `Browse and compare the best ${slug.replace(/-/g, ' ')} options. Searchable directory with ratings and reviews.`,
  }
}

export default async function CategoryPage({
  params,
  searchParams,
}: {
  params: Promise<{ slug: string }>
  searchParams: Promise<{ q?: string }>
}) {
  const { slug } = await params
  const { q } = await searchParams
  const supabase = await createClient()

  let query = supabase
    .from('listings')
    .select('*, categories!inner(name, slug)')
    .eq('categories.slug', slug)
    .eq('status', 'approved')

  if (q) {
    query = query.textSearch('search_vector', q, { type: 'websearch' })
  }

  const { data: listings } = await query.order('featured', { ascending: false })

  return (
    <div className="container mx-auto py-8">
      <h1 className="text-3xl font-bold mb-6 capitalize">{slug.replace(/-/g, ' ')}</h1>
      <form className="mb-6">
        <Input name="q" placeholder="Search listings..." defaultValue={q} className="max-w-md" />
      </form>
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        {listings?.map((listing) => (
          <Link key={listing.id} href={`/listing/${listing.slug}`}>
            <Card>
              <CardHeader>
                <CardTitle className="flex items-center gap-2">
                  {listing.title}
                  {listing.featured && <Badge>Featured</Badge>}
                </CardTitle>
              </CardHeader>
              <CardContent>
                <p className="text-muted-foreground line-clamp-2">{listing.description}</p>
              </CardContent>
            </Card>
          </Link>
        ))}
      </div>
    </div>
  )
}
```

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

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

```
// Paste this prompt into V0's AI chat:
// Build a listing detail page at app/listing/[slug]/page.tsx.
// Requirements:
// - Server Component that fetches the listing by slug with its reviews and average rating
// - Use generateMetadata to set title and description from the listing data
// - Display: listing title as h1, description, website link (Button with external link), location with map pin icon, tags as Badge components
// - Show average rating with filled/empty stars and total review count
// - List all reviews in Card components with Avatar, star rating, comment text, and date
// - Add a 'use client' ReviewForm component below with RadioGroup for star rating (1-5), Textarea for comment, and Button to submit
// - The form submits via a Server Action that validates with Zod (rating 1-5 required, comment 10+ chars) and inserts into the reviews table
// - Show a "Submit a listing" link to /submit in the sidebar
```

**Expected 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.

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

```
// Paste this prompt into V0's AI chat:
// Build two pages:
// 1. app/submit/page.tsx — a 'use client' listing submission form with:
//    - Input for title, website URL, location
//    - Textarea for description
//    - Select dropdown for category (fetched from categories table)
//    - Input for comma-separated tags
//    - Button to submit via Server Action with Zod validation (title required, valid URL, description 20+ chars)
//    - Show success message after submission: "Your listing is pending review"
// 2. app/admin/page.tsx — admin panel showing pending listings in a shadcn/ui Table with:
//    - Columns: title, category, submitted date, actions
//    - Two Button actions per row: Approve (green) and Reject (red) using Server Actions
//    - Approve sets status to 'approved', Reject sets to 'rejected'
//    - Badge for status column
//    - 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 example

File: `app/category/[slug]/page.tsx`

```typescript
import { createClient } from '@/lib/supabase/server'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import Link from 'next/link'
import { Metadata } from 'next'

type Props = {
  params: Promise<{ slug: string }>
  searchParams: Promise<{ q?: string }>
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const title = slug.replace(/-/g, ' ')
  return {
    title: `${title} Directory | Browse Top Listings`,
    description: `Explore the best ${title} options with reviews and ratings.`,
  }
}

export default async function CategoryPage({ params, searchParams }: Props) {
  const { slug } = await params
  const { q } = await searchParams
  const supabase = await createClient()

  let query = supabase
    .from('listings')
    .select('id, title, slug, description, tags, featured, categories!inner(name, slug)')
    .eq('categories.slug', slug)
    .eq('status', 'approved')

  if (q) {
    query = query.textSearch('search_vector', q, { type: 'websearch' })
  }

  const { data: listings } = await query.order('featured', { ascending: false })

  return (
    <div className="container mx-auto py-8">
      <h1 className="text-3xl font-bold mb-6 capitalize">
        {slug.replace(/-/g, ' ')}
      </h1>
      <form className="mb-6">
        <Input
          name="q"
          placeholder="Search listings..."
          defaultValue={q}
          className="max-w-md"
        />
      </form>
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        {listings?.map((listing) => (
          <Link key={listing.id} href={`/listing/${listing.slug}`}>
            <Card className="hover:shadow-md transition-shadow">
              <CardHeader>
                <CardTitle className="flex items-center gap-2">
                  {listing.title}
                  {listing.featured && <Badge>Featured</Badge>}
                </CardTitle>
              </CardHeader>
              <CardContent>
                <p className="text-muted-foreground line-clamp-2">
                  {listing.description}
                </p>
                <div className="flex flex-wrap gap-1 mt-3">
                  {listing.tags?.map((tag: string) => (
                    <Badge key={tag} variant="secondary">{tag}</Badge>
                  ))}
                </div>
              </CardContent>
            </Card>
          </Link>
        ))}
      </div>
    </div>
  )
}
```

## Common mistakes

- **Using client-side data fetching for listing pages instead of Server Components** — Beginners add 'use client' and useEffect to fetch data, which hurts SEO and initial load performance. Fix: 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.
- **Forgetting to add a tsvector index for full-text search** — Without an index, PostgreSQL scans the entire table on every search query, which gets slow as listings grow. Fix: Create a GIN index on the generated tsvector column: CREATE INDEX listings_search_idx ON listings USING GIN (search_vector).
- **Not setting RLS policies on the listings table** — Without RLS, any authenticated user could read pending or rejected listings, and potentially modify others' submissions. Fix: 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.

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

---

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