# How to Build a Directory Service with Lovable

- Tool: How to Build with Lovable
- Difficulty: Beginner
- Compatibility: Lovable Free or higher
- Last updated: April 2026

## TL;DR

Build a searchable business directory in Lovable with tsvector full-text search, a GIN index, categories, star ratings, and an admin moderation queue. The Command component provides instant keyboard-driven search. Listings go live only after admin approval — protecting directory quality from day one.

## Before you start

- Lovable Free account or higher
- Supabase project with URL and anon key saved to Cloud tab → Secrets
- Supabase Auth for admin users (email/password is sufficient)
- A list of initial categories to seed the database with after setup

## Step-by-step guide

### 1. Create the directory schema with full-text search

The search_vector tsvector column is the core of this schema. It combines name, description, and city into a single indexed field that PostgreSQL's full-text engine can query efficiently.

```
Create a business directory with Supabase. Set up these tables:

- categories: id (uuid pk), name (text unique not null), slug (text unique not null), icon (text, emoji or icon name), listing_count (int default 0), created_at
- listings: id (uuid pk), name (text not null), slug (text unique), description (text), website (text), phone (text), email (text), address (text), city (text), state (text), country (text default 'US'), category_id (uuid references categories), status (text check in ('pending','active','rejected'), default 'pending'), rejection_reason (text), submitted_by_email (text), average_rating (numeric(3,2) default 0), review_count (int default 0), featured (bool default false), search_vector (tsvector), created_at, updated_at
- reviews: id (uuid pk), listing_id (uuid references listings on delete cascade), reviewer_name (text not null), reviewer_email (text), rating (int check between 1 and 5, not null), body (text), is_approved (bool default true), created_at

RLS:
- listings: anon/authenticated SELECT where status='active', anon INSERT (new submissions with status='pending'), authenticated UPDATE/DELETE for admins only
- categories: anon/authenticated SELECT, authenticated INSERT/UPDATE/DELETE for admins
- reviews: anon/authenticated SELECT where is_approved=true, anon INSERT, authenticated UPDATE/DELETE for admins

Create a trigger on listings BEFORE INSERT/UPDATE to update search_vector:
NEW.search_vector := to_tsvector('english', coalesce(NEW.name,'') || ' ' || coalesce(NEW.description,'') || ' ' || coalesce(NEW.city,'') || ' ' || coalesce(NEW.state,''))

Create GIN index: CREATE INDEX ON listings USING GIN(search_vector)
Create index: CREATE INDEX ON listings(status, category_id)

Create a trigger on reviews AFTER INSERT/UPDATE/DELETE to recalculate listings.average_rating and listings.review_count.
```

> Pro tip: Ask Lovable to also create a trigger that auto-generates the listing slug from name on INSERT (lowercase, hyphens, unique). This avoids slug collisions when multiple businesses have similar names.

**Expected result:** Tables are created. The search_vector trigger fires on INSERT/UPDATE. GIN index is active. TypeScript types are generated.

### 2. Build the public directory search page

Create the main directory page with a Command search box, category filter sidebar, and listing Cards. The Command component handles both keyword search and 'no results' states gracefully.

```
Build the main directory page at src/pages/Directory.tsx.

Layout:
- Large heading 'Find Local Businesses'
- Command component (shadcn/ui) for search:
  - CommandInput with placeholder 'Search businesses...'
  - On input change (debounced 300ms): query supabase.from('listings').textSearch('search_vector', searchTerm).eq('status','active')
  - If searchTerm empty: show all listings
  - CommandEmpty: show 'No businesses found. Be the first to add one!' with a link to /submit
- Left sidebar (desktop) / top row (mobile): category filter
  - Show all categories with listing_count Badge
  - Clicking a category adds .eq('category_id', id) to the query
  - 'All' option to clear category filter
- Listing Cards in a grid (2 columns desktop, 1 mobile):
  - Business name (h3 link to /listing/:slug)
  - Category Badge
  - City, State text
  - Star rating display (filled/empty stars based on average_rating) + review_count
  - Description excerpt (max 120 chars)
  - Website Button (external link)
  - Featured listings get a 'Featured' Badge and slightly elevated Card style
- Sort options: relevance (default for search), newest, highest rated, most reviewed
```

> Pro tip: For the star rating display component, ask Lovable to build a StarRating component that takes a value (0-5, can be decimal) and renders full, half, and empty stars using Unicode characters or SVG icons. Reuse this component on listing Cards and the detail page.

**Expected result:** The directory renders listing Cards. The Command search filters instantly. Clicking a category shows only that category's listings. Sorting changes the order.

### 3. Build the listing submission form

Create the public submission form for business owners. Submissions go in as 'pending' — the admin reviews before they appear in the directory.

```
Build a listing submission page at src/pages/SubmitListing.tsx.

Form fields (react-hook-form + zod):
- Business Name (Input, required)
- Category (Select, populated from categories table)
- Description (Textarea, min 50 chars, max 500 chars, show char count)
- Website (Input type url, optional)
- Phone (Input, optional)
- Email (Input type email, optional)
- Address (Input, optional)
- City (Input, required)
- State (Input, required)
- Your Email (Input type email, required, stored in submitted_by_email — for moderation contact)

On submit:
- Insert to listings with status='pending'
- Show a success Card: 'Thank you! Your listing has been submitted for review. We typically review within 24-48 hours.'
- Reset the form

Add a note below the form: 'All submissions are reviewed before appearing in the directory. We do not publish personal contact information without your permission.'
```

**Expected result:** The submission form creates a pending listing. No validation errors appear for optional fields left blank. The success message appears after submission.

### 4. Build the admin moderation queue

Create the admin page where submitted listings are reviewed. Admins can approve listings (making them live) or reject them with a reason that is stored for reference.

```
Build an admin moderation page at src/pages/admin/Moderation.tsx. Protect this route to require authentication.

Layout:
- Tabs: Pending (default), Active, Rejected — each fetches listings filtered by status
- Pending tab DataTable columns: Business Name, Category, City/State, Submitted By Email, Submitted At, Actions
- Actions per row:
  - 'Preview' Button: opens a Sheet showing the full listing details (all fields, formatted as the public listing page would look)
  - 'Approve' Button (green): calls supabase.from('listings').update({ status: 'active' }).eq('id', id). Updates the listing_count on the category.
  - 'Reject' Button (destructive): opens a Dialog with a rejection_reason Textarea. On confirm, sets status='rejected' and stores the reason.
- Active tab: shows all active listings with an 'Archive' action that sets status back to 'pending'
- Rejected tab: shows rejected listings with the rejection_reason column visible and a 'Reconsider' action to move back to 'pending'

Add a badge count in the tab trigger showing pending item count, updating in real time via Supabase Realtime.
```

> Pro tip: Add Supabase Realtime on the listings table filtered to status='pending' so the Pending tab count badge updates automatically when new submissions arrive without the admin needing to refresh.

**Expected result:** The admin moderation queue shows pending listings. Approving a listing makes it appear in the public directory. Rejecting stores the reason. The pending count badge updates in real time.

## Complete code example

File: `src/components/StarRating.tsx`

```typescript
import React from 'react'
import { cn } from '@/lib/utils'

interface StarRatingProps {
  value: number
  max?: number
  size?: 'sm' | 'md' | 'lg'
  className?: string
  interactive?: boolean
  onRate?: (rating: number) => void
}

export function StarRating({
  value,
  max = 5,
  size = 'md',
  className,
  interactive = false,
  onRate,
}: StarRatingProps) {
  const sizeClasses = { sm: 'w-3 h-3', md: 'w-4 h-4', lg: 'w-6 h-6' }

  return (
    <div className={cn('flex items-center gap-0.5', className)}>
      {Array.from({ length: max }, (_, i) => {
        const filled = i + 1 <= Math.floor(value)
        const half = !filled && i < value && value - i > 0
        return (
          <button
            key={i}
            type="button"
            onClick={() => interactive && onRate?.(i + 1)}
            className={cn(
              sizeClasses[size],
              'relative',
              interactive ? 'cursor-pointer hover:scale-110 transition-transform' : 'cursor-default'
            )}
          >
            <svg viewBox="0 0 20 20" className={cn(sizeClasses[size])}>
              <defs>
                <linearGradient id={`half-${i}`}>
                  <stop offset="50%" stopColor="#f59e0b" />
                  <stop offset="50%" stopColor="#d1d5db" />
                </linearGradient>
              </defs>
              <path
                d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
                fill={filled ? '#f59e0b' : half ? `url(#half-${i})` : '#d1d5db'}
              />
            </svg>
          </button>
        )
      })}
    </div>
  )
}
```

## Common mistakes

- **Not adding the GIN index on search_vector** — Without the GIN index, tsvector full-text search performs a sequential table scan. This is acceptable at 100 listings but becomes very slow at 10,000 listings. Fix: Run: CREATE INDEX ON listings USING GIN(search_vector). Ask Lovable to include this in the migration. Verify it exists in Supabase Dashboard → Database → Indexes.
- **Allowing anon users to UPDATE listing status** — If the RLS INSERT policy for anon is too broad, a motivated user could update existing listings including changing their status to 'active'. Fix: The anon INSERT policy should only allow inserting rows with status='pending'. Add a CHECK constraint to the RLS policy or use a row-level check: WITH CHECK (status = 'pending' AND submitted_by_email IS NOT NULL).
- **Computing average_rating in the frontend on every render** — Calculating the average of all reviews on every listing card render requires fetching all review rows — N reviews per listing in the query, which is slow. Fix: Use a database trigger to maintain average_rating and review_count on the listings table. After every review INSERT, UPDATE, or DELETE, recalculate and update these denormalized columns. The listing card query fetches these pre-computed values in a single row.
- **Not implementing pagination for large directory listings** — Fetching all active listings in a single query works fine at 50 entries but becomes slow and expensive at 5,000. The browser also has to render hundreds of Cards at once, causing sluggish scroll performance. Fix: Add .range(from, from + pageSize - 1) to the Supabase query and use the { count: 'exact' } option to get the total count. Render a Pagination component at the bottom of the listing grid. Default to 20 listings per page and allow users to change to 50 or 100.

## Best practices

- Always moderate user-submitted listings before displaying them publicly. The pending/active/rejected workflow protects directory quality and prevents spam from day one.
- Denormalize review statistics (average_rating, review_count) into the listings table via triggers. Never JOIN to the reviews table just to show a star rating on a card — it multiplies query cost.
- Use the tsvector trigger to keep search_vector current on every INSERT and UPDATE. A stale search index returns incorrect results and erodes user trust in the search feature.
- Store the submitter's email in submitted_by_email even if they are anonymous. This gives you a contact for follow-up questions and a way to notify them when their listing is approved.
- Add a slug to every listing and link to /listing/:slug instead of /listing/:id. Slugs are shareable, readable, and SEO-friendly. Numeric or UUID IDs in URLs are less so.
- Index listings on (status, category_id) to make category-filtered searches fast. Add (status, featured DESC, created_at DESC) for the default sort.

## Frequently asked questions

### How does the tsvector search handle misspellings?

Standard tsvector full-text search uses stemming (matches 'running' and 'run') but does not handle misspellings. For fuzzy search that tolerates typos, install the pg_trgm extension in Supabase and add a trigram index: CREATE INDEX ON listings USING GIN(name gin_trgm_ops). Then use ILIKE or similarity() for fuzzy matching alongside or instead of textSearch.

### Can I let listing owners edit their own entries after approval?

Add a claimed_by column to listings. Update the RLS UPDATE policy to allow authenticated users where claimed_by = auth.uid(). When an owner edits a listing, set a needs_review boolean back to true and put it in a secondary moderation queue. This balances owner autonomy with directory quality control.

### How do I handle duplicate listing submissions?

Add a unique constraint on LOWER(name) + city or on website URL. In the submission form, check for existing active listings with the same name and city before submitting. Show a warning: 'A listing for [Business Name] in [City] already exists. Is this the same business?' with a link to the existing listing and a confirm button to proceed anyway.

### Can anonymous users leave reviews?

Yes — the current setup allows anon INSERT on reviews with a reviewer_name text field. To reduce spam, add a honeypot field (a hidden input that bots fill in but humans do not) and reject submissions where it is filled. For stronger protection, require email verification: collect reviewer_email and send a confirmation before is_approved becomes true.

### How do I seed the categories table?

In Supabase Dashboard → SQL Editor, run an INSERT statement with your initial categories: INSERT INTO categories (name, slug, icon) VALUES ('Restaurants', 'restaurants', 'fork-knife'), ('Services', 'services', 'briefcase'), ('Retail', 'retail', 'shopping-bag'); Ask Lovable to also generate this SQL as part of the migration so it runs automatically on setup.

### Is there a way to prevent the same person from leaving multiple reviews?

For authenticated users, add a UNIQUE(listing_id, reviewer_id) constraint where reviewer_id references auth.users. For anonymous reviews, use reviewer_email as a soft uniqueness check — check for an existing review with the same email before inserting. Add an upsert with onConflict to update an existing review rather than creating a duplicate.

### Can RapidDev help me add claimed listings and business dashboards?

RapidDev builds verified business directory features including ownership claiming flows, business dashboards with analytics, photo uploads, and featured listing billing. Reach out if you need the directory to go beyond the public submission and moderation pattern.

---

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