Skip to main content
RapidDev - Software Development Agency

How to Build a Directory Service with Lovable

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.

What you'll build

  • listings and categories tables with a tsvector search_vector column and GIN index
  • Command search component for instant keyword search across all listing fields
  • Category filter sidebar with listing counts per category
  • Star rating system with average score on each listing card
  • Admin moderation queue showing pending submissions with approve and reject actions
  • Listing submission form for public users (unauthenticated) with status 'pending'
  • Single listing detail page with full info, reviews, and a contact form
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner12 min read1.5–2 hoursLovable Free or higherApril 2026RapidDev Engineering Team
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.

What you're building

A directory service lives or dies by search quality and listing trust. The tsvector full-text search indexes name, description, and category together so a single search box finds relevant listings without separate fields for each attribute. The GIN index makes this search instant even at thousands of entries.

Listing quality is managed through a moderation queue. When someone submits a listing, it enters with status 'pending'. An admin reviews it and either approves (status = 'active') or rejects it with a reason. Only active listings appear in the public search. This prevents spam, duplicate entries, and low-quality submissions.

Reviews and ratings are attached to listings. Each visitor (optionally authenticated) can leave a 1-5 star rating and a text review. The average rating is computed as a Supabase generated column on the listings table and updated by a trigger after each review INSERT or DELETE. The listing card shows the average rating and review count.

Final result

A moderated, searchable business directory with full-text search, categories, ratings, and an admin queue — ready to launch with real listings.

Tech stack

LovableFrontend directory and admin UI
SupabaseDatabase and Auth
shadcn/uiCommand, Badge, Card, Table, Select components

Prerequisites

  • 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

Build steps

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.

prompt.txt
1Create a business directory with Supabase. Set up these tables:
2
3- 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
4- 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
5- 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
6
7RLS:
8- listings: anon/authenticated SELECT where status='active', anon INSERT (new submissions with status='pending'), authenticated UPDATE/DELETE for admins only
9- categories: anon/authenticated SELECT, authenticated INSERT/UPDATE/DELETE for admins
10- reviews: anon/authenticated SELECT where is_approved=true, anon INSERT, authenticated UPDATE/DELETE for admins
11
12Create a trigger on listings BEFORE INSERT/UPDATE to update search_vector:
13NEW.search_vector := to_tsvector('english', coalesce(NEW.name,'') || ' ' || coalesce(NEW.description,'') || ' ' || coalesce(NEW.city,'') || ' ' || coalesce(NEW.state,''))
14
15Create GIN index: CREATE INDEX ON listings USING GIN(search_vector)
16Create index: CREATE INDEX ON listings(status, category_id)
17
18Create 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.

prompt.txt
1Build the main directory page at src/pages/Directory.tsx.
2
3Layout:
4- Large heading 'Find Local Businesses'
5- Command component (shadcn/ui) for search:
6 - CommandInput with placeholder 'Search businesses...'
7 - On input change (debounced 300ms): query supabase.from('listings').textSearch('search_vector', searchTerm).eq('status','active')
8 - If searchTerm empty: show all listings
9 - CommandEmpty: show 'No businesses found. Be the first to add one!' with a link to /submit
10- Left sidebar (desktop) / top row (mobile): category filter
11 - Show all categories with listing_count Badge
12 - Clicking a category adds .eq('category_id', id) to the query
13 - 'All' option to clear category filter
14- Listing Cards in a grid (2 columns desktop, 1 mobile):
15 - Business name (h3 link to /listing/:slug)
16 - Category Badge
17 - City, State text
18 - Star rating display (filled/empty stars based on average_rating) + review_count
19 - Description excerpt (max 120 chars)
20 - Website Button (external link)
21 - Featured listings get a 'Featured' Badge and slightly elevated Card style
22- 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.

prompt.txt
1Build a listing submission page at src/pages/SubmitListing.tsx.
2
3Form fields (react-hook-form + zod):
4- Business Name (Input, required)
5- Category (Select, populated from categories table)
6- Description (Textarea, min 50 chars, max 500 chars, show char count)
7- Website (Input type url, optional)
8- Phone (Input, optional)
9- Email (Input type email, optional)
10- Address (Input, optional)
11- City (Input, required)
12- State (Input, required)
13- Your Email (Input type email, required, stored in submitted_by_email for moderation contact)
14
15On submit:
16- Insert to listings with status='pending'
17- Show a success Card: 'Thank you! Your listing has been submitted for review. We typically review within 24-48 hours.'
18- Reset the form
19
20Add 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.

prompt.txt
1Build an admin moderation page at src/pages/admin/Moderation.tsx. Protect this route to require authentication.
2
3Layout:
4- Tabs: Pending (default), Active, Rejected each fetches listings filtered by status
5- Pending tab DataTable columns: Business Name, Category, City/State, Submitted By Email, Submitted At, Actions
6- Actions per row:
7 - 'Preview' Button: opens a Sheet showing the full listing details (all fields, formatted as the public listing page would look)
8 - 'Approve' Button (green): calls supabase.from('listings').update({ status: 'active' }).eq('id', id). Updates the listing_count on the category.
9 - 'Reject' Button (destructive): opens a Dialog with a rejection_reason Textarea. On confirm, sets status='rejected' and stores the reason.
10- Active tab: shows all active listings with an 'Archive' action that sets status back to 'pending'
11- Rejected tab: shows rejected listings with the rejection_reason column visible and a 'Reconsider' action to move back to 'pending'
12
13Add 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

src/components/StarRating.tsx
1import React from 'react'
2import { cn } from '@/lib/utils'
3
4interface StarRatingProps {
5 value: number
6 max?: number
7 size?: 'sm' | 'md' | 'lg'
8 className?: string
9 interactive?: boolean
10 onRate?: (rating: number) => void
11}
12
13export function StarRating({
14 value,
15 max = 5,
16 size = 'md',
17 className,
18 interactive = false,
19 onRate,
20}: StarRatingProps) {
21 const sizeClasses = { sm: 'w-3 h-3', md: 'w-4 h-4', lg: 'w-6 h-6' }
22
23 return (
24 <div className={cn('flex items-center gap-0.5', className)}>
25 {Array.from({ length: max }, (_, i) => {
26 const filled = i + 1 <= Math.floor(value)
27 const half = !filled && i < value && value - i > 0
28 return (
29 <button
30 key={i}
31 type="button"
32 onClick={() => interactive && onRate?.(i + 1)}
33 className={cn(
34 sizeClasses[size],
35 'relative',
36 interactive ? 'cursor-pointer hover:scale-110 transition-transform' : 'cursor-default'
37 )}
38 >
39 <svg viewBox="0 0 20 20" className={cn(sizeClasses[size])}>
40 <defs>
41 <linearGradient id={`half-${i}`}>
42 <stop offset="50%" stopColor="#f59e0b" />
43 <stop offset="50%" stopColor="#d1d5db" />
44 </linearGradient>
45 </defs>
46 <path
47 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"
48 fill={filled ? '#f59e0b' : half ? `url(#half-${i})` : '#d1d5db'}
49 />
50 </svg>
51 </button>
52 )
53 })}
54 </div>
55 )
56}

Customization ideas

Claimed listing profiles

Add a claimed boolean and claimed_by (references auth.users) to listings. Business owners click 'Claim this listing' which sends an email verification. Once claimed, they can edit their listing details and add photos. Claimed listings get a 'Verified' Badge.

Featured listing tiers

Add a featured_until timestamptz column. Build a Stripe checkout for a 'Feature my listing for 30 days' product. After payment, an Edge Function sets featured=true and featured_until=now()+30 days. A daily cron job un-features expired listings. Featured listings appear at the top of search results.

Map view integration

Add latitude and longitude columns to listings (populated at submission time via a geocoding API call in an Edge Function). Add a Map tab on the directory page that renders a Leaflet.js or MapLibre map with pins for each visible listing. Clicking a pin shows a popup with the listing name and a link to the detail page.

Business hours

Add a business_hours jsonb column storing open/close times per day of the week. The listing detail page shows a 'Hours' section with a table of days and times. Add an 'Open Now' Badge that computes based on the current day and time in the listing's timezone.

Common pitfalls

Pitfall: Not adding the GIN index on search_vector

How to avoid: 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.

Pitfall: Allowing anon users to UPDATE listing status

How to avoid: 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).

Pitfall: Computing average_rating in the frontend on every render

How to avoid: 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.

Pitfall: Not implementing pagination for large directory listings

How to avoid: 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.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I have a Supabase business directory with a listings table that has a tsvector search_vector column and a GIN index. I want to build a ranked search that combines full-text relevance with a boost for featured listings and a boost for listings with higher review counts. Show me the PostgreSQL query using ts_rank or ts_rank_cd that weights featured=true and review_count as secondary sort factors after relevance.

Lovable Prompt

Add a listing detail page at /listing/:slug. Fetch the listing by slug from Supabase and show: business name (h1), category Badge, address, website link, phone, a StarRating component with the average score and review count, the full description, and a 'Leave a Review' section at the bottom. The review form has reviewer_name (Input), rating (interactive StarRating component), and body (Textarea). On submit, insert to the reviews table. Show existing approved reviews below the form.

Build Prompt

In Supabase, write a trigger function update_listing_ratings() that fires AFTER INSERT, UPDATE, or DELETE on the reviews table. It should recalculate the average rating and review count for the affected listing: UPDATE listings SET average_rating = (SELECT AVG(rating) FROM reviews WHERE listing_id = NEW.listing_id AND is_approved = true), review_count = (SELECT COUNT(*) FROM reviews WHERE listing_id = NEW.listing_id AND is_approved = true) WHERE id = NEW.listing_id.

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.