TL;DR
The one-paragraph version before you dive in.
Paste the starter prompt into Lovable Build mode and get a working browsable directory: listings with categories, Postgres full-text search, a claim-listing flow, and a lead-capture form — all backed by Lovable Cloud with public-read listings and per-owner write RLS. Critical upfront decision: if your business model requires Google to index every listing page, you must migrate to Next.js for SSR after the build. Lovable's Vite SPA is invisible to most crawlers.
Setup checklist
Complete these steps in Lovable before pasting the starter prompt — takes about 5 minutes.
Cloud tab settings
Database
Stores listings, categories, leads, and profiles. The starter prompt creates the migration with a generated tsvector column + GIN index for fast full-text search.
- 1Click the + button next to Preview to open the Cloud panel
- 2Click Database — leave empty; the starter prompt creates all tables
- 3After the migration runs, verify the listings.search_vector column exists in Cloud → Database → Table Editor
Auth
Listing owners sign in with email/password to manage their claimed listings. Visitors browse without an account. The claim flow signs them in mid-process if not already authenticated.
- 1Cloud tab → Users & Auth → confirm Email sign-in is enabled
- 2Leave 'Allow new users to sign up' ON — owners register during the claim flow
- 3Enable email confirmations (Cloud tab → Users & Auth → Email settings) to prevent fraudulent claims
Storage
Stores listing logos and cover photos uploaded by owners. One public bucket so images render on listing cards without auth.
- 1Cloud tab → Storage → Create bucket 'listing-media', toggle Public ON
- 2The starter prompt references 'listing-media' throughout — do not rename it
Edge Functions
search-listings uses the generated tsvector for ranked full-text search. submit-lead adds rate-limiting and sends notifications. verify-claim handles one-time token verification.
- 1Cloud tab → Edge Functions — leave empty; functions deploy automatically from supabase/functions/
- 2After starter prompt runs, verify search-listings, submit-lead, and verify-claim appear here
Secrets (Cloud tab → Secrets)
RESEND_API_KEYPurpose: Sends claim verification emails (one-time token link) and lead notification emails to listing owners
Where to get it: https://resend.com/api-keys — free account, Create API Key with Sending access, copy key starting with re_
SITE_URLPurpose: Used to build the claim verification link in emails: https://{SITE_URL}/claim/verify/{token}
Where to get it: After publishing (top-right Publish button), copy the production URL or custom domain
Preflight checklist
- You're in a fresh Lovable project (Vite + React + TypeScript + Tailwind + shadcn/ui already scaffolded)
- You're on Pro $25/mo — the claim flow and full-text search iteration will exceed Free's 30-credit cap
- Critical architecture decision BEFORE pasting: if your directory needs Google to index individual listing pages for SEO traffic, Lovable's Vite SPA will underperform. Plan to either export to GitHub and migrate to Next.js, or add vite-plugin-ssg for static pre-rendering, before launch. The Lovable build is perfect for: internal directories, member-only directories, or SEO-light proof-of-concepts.
- You have verified your Resend sending domain (add SPF + DKIM DNS records at resend.com/domains) before testing email flows
- Seed a few categories manually after the migration runs: Cloud → Database → Table Editor → categories → insert rows
The starter prompt — paste this first
Copy this. Paste it into Lovable Agent Mode (the default chat at the bottom-left of the editor). Hit send.
Build a directory service app. Stack: React + Vite + TypeScript + Tailwind + shadcn/ui (already scaffolded), React Router v6, Supabase JS client against Lovable Cloud.
## Database schema (migration: supabase/migrations/0001_directory_schema.sql)
First enable extension:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS unaccent;
Create four tables:
1. `profiles` — id (uuid references auth.users.id on delete cascade, primary key), full_name (text), email (text), claimed_listings_count (int default 0), created_at (timestamptz default now()).
RLS: enable. FOR SELECT/UPDATE: USING (id = auth.uid()).
2. `categories` — id (uuid pk default gen_random_uuid()), slug (text unique not null), name (text not null), parent_id (uuid nullable references categories.id), listing_count (int default 0), created_at (timestamptz default now()).
RLS: enable. FOR SELECT TO anon, authenticated USING (true). No client INSERT/UPDATE — seed manually or via admin.
3. `listings` — id (uuid pk default gen_random_uuid()), slug (text unique not null), name (text not null), description (text), category_id (uuid references categories.id), address (text), lat (float), lng (float), website (text), phone (text), logo_path (text), owner_id (uuid nullable references auth.users.id), claimed (bool default false), featured (bool default false), is_deleted (bool default false), confirmation_token (text unique nullable), created_at (timestamptz default now()),
search_vector tsvector GENERATED ALWAYS AS (to_tsvector('english', name || ' ' || coalesce(description, ''))) STORED.
CREATE INDEX idx_listings_search ON listings USING GIN (search_vector);
CREATE INDEX idx_listings_category ON listings (category_id) WHERE is_deleted = false;
RLS: enable.
FOR SELECT TO anon, authenticated USING (is_deleted = false).
FOR UPDATE TO authenticated USING (owner_id = auth.uid() AND claimed = true).
FOR UPDATE admin: USING ((auth.jwt() -> 'app_metadata' ->> 'role') = 'admin').
No client INSERT — listings are seeded by admin or via import. No client DELETE — use is_deleted = true.
4. `leads` — id (uuid pk default gen_random_uuid()), listing_id (uuid references listings.id not null), name (text), email (text not null), phone (text), message (text not null), source (text default 'web'), created_at (timestamptz default now()).
RLS: enable.
FOR INSERT TO anon, authenticated WITH CHECK (true) — anyone can submit a lead.
FOR SELECT TO authenticated USING (EXISTS (SELECT 1 FROM listings l WHERE l.id = leads.listing_id AND l.owner_id = auth.uid())) — listing owner reads own leads only.
NO SELECT policy for anon — visitors cannot read any leads via the API.
## Layouts
`src/layouts/PublicLayout.tsx` — top nav: Logo, 'Browse Categories' link, 'List Your Business' CTA button (secondary), 'Sign in' link. Prominent search bar below nav on homepage only. Footer with copyright.
`src/layouts/OwnerLayout.tsx` — sidebar (240px): My Listings / Leads / Settings. Protected by `<OwnerGuard>` that checks supabase auth session; redirects to /signin if not authenticated.
## Pages (add to src/App.tsx)
- `/` — Home.tsx: hero with search bar (links to /search?q=), category grid (CategoryGrid component — shows all root categories with icon placeholder and listing_count), featured listings section (listings where featured=true), recent listings grid.
- `/category/[slug]` — Category.tsx: category name + description, filter chips (sub-categories if any), grid of ListingCard components for that category. Pagination (25 per page).
- `/listing/[slug]` — ListingDetail.tsx: logo, name, description, category badge, address, website/phone links, 'Contact' section with LeadCaptureForm. Show 'Claimed' badge if claimed=true.
- `/search` — Search.tsx: reads q from URL query param, calls GET /functions/v1/search-listings?q={q}&limit=25. Shows ranked results as ListingCard grid. Empty state when no results.
- `/claim/[listing-slug]` — Claim.tsx: shows the listing name, explains the claim process, email input form. If user is not signed in, shows sign-in/sign-up inline. Submit button calls /functions/v1/verify-claim to send the one-time token email.
- `/claim/verify` — ClaimVerify.tsx: reads token from URL query param, calls /functions/v1/verify-claim?token={token}&action=confirm. On success: 'Listing claimed!' redirect to /owner/listings.
- `/list-your-business` — ListYourBusiness.tsx: CTA explaining the directory, 'Find and claim your listing' section with search-by-name, link to /claim/[slug] for each result.
- `/owner/dashboard` — owner/Dashboard.tsx: table of claimed listings with name, lead count, 'Edit' link.
- `/owner/listings/[id]/edit` — owner/EditListing.tsx: edit form for name, description, address, website, phone, logo upload. Save calls supabase.from('listings').update().
- `/owner/leads` — owner/Leads.tsx: table of all leads across owned listings. Columns: listing, name, email, phone, message, date.
## Key components
`src/components/ListingCard.tsx` — logo (fallback to initials avatar), name, category badge, address, description excerpt (max 120 chars), 'View' button. Show 'Featured' badge if featured=true.
`src/components/CategoryGrid.tsx` — grid of category cards with icon placeholder, name, listing_count badge.
`src/components/SearchBar.tsx` — controlled input with debounce (300ms). On change, navigate to /search?q={value}. On the homepage, place prominently in hero. In the nav on other pages, place compact.
`src/components/LeadCaptureForm.tsx` — name, email, phone, message textarea, submit button. On submit: POST to /functions/v1/submit-lead with { listing_id, name, email, phone, message }. Show success message 'Your message has been sent to the listing owner.' on 200. Show error on failure.
`src/components/ClaimDialog.tsx` — modal that appears after finding your listing on /list-your-business. Explains the claim process and links to /claim/[slug].
`src/components/OwnerGuard.tsx` — checks auth, redirects to /signin if not authenticated.
## Edge functions
`supabase/functions/search-listings/index.ts` — GET with query params q (search query), limit (default 25), offset (default 0), category_slug (optional filter). Steps:
1. CORS preflight.
2. Use anon Supabase client (public search).
3. Build query: SELECT *, ts_rank(search_vector, websearch_to_tsquery('english', $1)) AS rank FROM listings WHERE is_deleted = false AND search_vector @@ websearch_to_tsquery('english', $1). If category_slug provided: add AND category_id = (SELECT id FROM categories WHERE slug = $2). ORDER BY rank DESC, name ASC. LIMIT $3 OFFSET $4.
4. Return { listings: [...], total: count }.
`supabase/functions/submit-lead/index.ts` — POST JSON { listing_id, name, email, phone, message }. Steps:
1. CORS preflight.
2. Validate: listing_id, email, and message are required. Reject if message < 10 chars.
3. Rate limit: check if more than 3 leads from the same IP in the last hour (store ip in leads table or use a simple in-memory check). Return 429 if exceeded.
4. INSERT INTO leads using service-role client (bypasses RLS for insert since anon INSERT is already permitted, but service-role is safer for the email step).
5. Fetch the listing's owner_id, then the owner's email from profiles (or auth.users). Send email via Resend to the owner: 'New lead from [name] for your listing [listing.name]'. Include message body and reply-to set to the lead's email.
6. Return 200 { ok: true }.
`supabase/functions/verify-claim/index.ts` — handles both sending and confirming:
- POST { listing_slug, email } → generate UUID token, store in listings.confirmation_token, send email to provided email via Resend: 'Confirm your claim of [listing.name]' with link https://{SITE_URL}/claim/verify?token={token}. Return 200 { ok: true }.
- GET ?token={token}&action=confirm → find listing where confirmation_token = token. If found: set owner_id = auth.uid() (from auth header), claimed = true, confirmation_token = null. Return 200 { ok: true, listing_slug }. If not found: return 404.
## Styling
Trustworthy directory feel: neutral palette (slate/zinc background, white cards), card-based listings with logo + name + category badge. Prominent search bar hero on homepage. shadcn Card, Avatar, Badge, Command (for search typeahead), Command, Pagination, Dialog. Clean, professional — think Yelp meets Clutch.co.
Generate migration first, then layouts, then pages in order, then edge functions.What this prompt generates
- Creates a SQL migration with 4 tables, generated tsvector search column + GIN index, INSERT-only anon RLS on leads, and GIN + partial indexes on listings
- Scaffolds PublicLayout with SearchBar and OwnerLayout with sidebar and OwnerGuard
- Generates 10 pages: homepage with category grid, category browse, listing detail, search results, claim initiation, claim verification, list-your-business CTA, owner dashboard, edit listing, and leads table
- Creates search-listings edge function with ranked Postgres tsvector full-text search
- Creates submit-lead edge function with IP rate limiting and owner notification via Resend, and verify-claim edge function with one-time token email
Paste into: Lovable Agent Mode (the default chat at the bottom-left of the editor)
Expected output
What Lovable will generate after the starter prompt runs successfully.
Files
supabase/migrations/0001_directory_schema.sql4 tables + RLS + generated tsvector + GIN index + pg_trgm extension
src/layouts/PublicLayout.tsxTop nav with search bar and 'List Your Business' CTA
src/layouts/OwnerLayout.tsxOwner sidebar with OwnerGuard
src/components/OwnerGuard.tsxAuth gate — redirects to /signin if not authenticated
src/pages/Home.tsxHero with search, category grid, featured + recent listings
src/pages/Category.tsxCategory listing grid with pagination
src/pages/ListingDetail.tsxFull listing view with lead capture form
src/pages/Search.tsxFull-text search results from search-listings edge function
src/pages/Claim.tsxClaim initiation form
src/pages/ClaimVerify.tsxToken verification page
src/pages/ListYourBusiness.tsxCTA page with listing search and claim link
src/pages/owner/Dashboard.tsxClaimed listings table
src/pages/owner/EditListing.tsxListing edit form with image upload
src/pages/owner/Leads.tsxAll leads across owner's listings
src/components/ListingCard.tsxCard with logo, name, category, description excerpt
src/components/CategoryGrid.tsxCategory card grid with listing counts
src/components/SearchBar.tsxDebounced search input navigating to /search
src/components/LeadCaptureForm.tsxContact form calling submit-lead edge function
supabase/functions/search-listings/index.tsRanked tsvector full-text search with optional category filter
supabase/functions/submit-lead/index.tsRate-limited lead insert with owner email notification
supabase/functions/verify-claim/index.tsSends claim token email and confirms claim on token match
Routes
Homepage: search hero, category grid, featured listings
Category browse with listing grid and pagination
Listing detail with lead capture form
Full-text search results page
Claim initiation — sends token email
Token confirmation — sets owner_id and claimed=true
CTA page with listing lookup
Owner: claimed listings table
Owner: listing edit form
Owner: all leads across their listings
DB Tables
listings| Column | Type |
|---|---|
| id | uuid primary key |
| slug | text unique not null |
| name | text not null |
| description | text |
| owner_id | uuid nullable references auth.users |
| claimed | bool default false |
| featured | bool default false |
| is_deleted | bool default false |
| search_vector | tsvector GENERATED ALWAYS AS (to_tsvector('english', name || ' ' || coalesce(description,''))) STORED |
RLS: Public read where is_deleted=false. Per-owner UPDATE where owner_id=auth.uid() AND claimed=true. No client INSERT or DELETE.
leads| Column | Type |
|---|---|
| id | uuid primary key |
| listing_id | uuid references listings |
| name | text |
| text not null | |
| phone | text |
| message | text not null |
RLS: Anon + authenticated INSERT allowed. NO SELECT for anon. Authenticated SELECT only for owners of the linked listing.
categories| Column | Type |
|---|---|
| id | uuid primary key |
| slug | text unique not null |
| name | text not null |
| parent_id | uuid nullable references categories |
| listing_count | int default 0 |
RLS: Public read. Admin write only.
profiles| Column | Type |
|---|---|
| id | uuid references auth.users primary key |
| full_name | text |
| text | |
| claimed_listings_count | int default 0 |
RLS: Per-user read+write.
Components
ListingCardLogo, name, category badge, description excerpt, 'Featured' badge if applicable
CategoryGridCategory cards with listing_count badges
SearchBarDebounced input navigating to /search?q=
LeadCaptureFormContact form calling submit-lead, shows success/error state
ClaimDialogModal explaining claim process, links to /claim/[slug]
OwnerGuardChecks auth session, redirects unauthenticated visitors
Follow-up prompts
Paste these into Agent Mode one by one, in order, after the starter prompt finishes.
Harden leads RLS: ensure INSERT-only, no anon SELECT
Guarantees leads are INSERT-only for anonymous visitors — prevents any visitor from reading all submitted contact details via the Supabase API URL
Audit the leads table RLS policies and confirm they are INSERT-only for anon:
Run these checks in Cloud → Database → SQL Editor:
1. List current policies: SELECT policyname, cmd, roles FROM pg_policies WHERE tablename = 'leads';
2. If you see any policy with cmd='SELECT' and roles includes '{anon}', drop it immediately: DROP POLICY [policy_name] ON leads;
3. The only correct policies on leads are:
- FOR INSERT TO anon, authenticated WITH CHECK (true); — allows anyone to submit a lead
- FOR SELECT TO authenticated USING (EXISTS (SELECT 1 FROM listings l WHERE l.id = leads.listing_id AND l.owner_id = auth.uid())); — owner reads own listing's leads only
4. Verify in an incognito browser (not signed in): open the browser console and run:
const { data } = await supabase.from('leads').select(); console.log(data);
This should return [] (empty array) or a permission error — never any rows.
5. If the verification returns rows, your anon SELECT policy is wrong. Drop it and re-add with the correct owner-only policy above.When to use: Paste immediately after the starter prompt, before any real user submissions — this is the #1 RLS gotcha in directories
Add Postgres full-text search with ranking and snippet highlighting
Ranked search results with relevance scoring and highlighted keyword snippets in results
Update supabase/functions/search-listings/index.ts to use full-text search with ts_rank scoring and headline highlighting:
1. Replace the basic search query with:
SELECT *,
ts_rank(search_vector, websearch_to_tsquery('english', $1)) AS rank,
ts_headline('english', description, websearch_to_tsquery('english', $1), 'MaxWords=20, MinWords=10, ShortWord=3, HighlightAll=false, MaxFragments=1') AS search_snippet
FROM listings
WHERE is_deleted = false
AND search_vector @@ websearch_to_tsquery('english', $1)
ORDER BY rank DESC, name ASC
LIMIT $2 OFFSET $3;
2. In Search.tsx, use search_snippet instead of description when rendering search results. If search_snippet is provided, show it with the highlighted terms bolded (parse the <b> tags Postgres returns in the snippet). Fall back to the first 120 chars of description if no snippet.
3. Add a total count query for pagination: SELECT COUNT(*) FROM listings WHERE is_deleted = false AND search_vector @@ websearch_to_tsquery('english', $1);
4. Return { listings, total, query } from the function. In Search.tsx, show 'X results for "query"' above the grid.When to use: Paste right after the leads RLS audit — default LIKE search is too slow past ~5K listings and has no ranking
Add claim-listing verification flow with one-time token email
Secure one-time token claim flow with email verification — prevents anyone from claiming a listing without access to the owner's email
Harden the claim verification flow:
1. In supabase/functions/verify-claim/index.ts (POST flow — sending the claim email):
- Accept { listing_slug, claimant_email, claimant_name } in POST body.
- Verify the listing exists and is not already claimed (claimed=true). If claimed, return 409 { error: 'listing_already_claimed' }.
- Generate token: crypto.randomUUID().
- UPDATE listings SET confirmation_token = token WHERE slug = listing_slug.
- Send email via Resend to claimant_email:
Subject: 'Confirm your claim of [listing.name]'
Body: 'Click this link to confirm you are the owner: ${SITE_URL}/claim/verify?token=${token}&listing=${listing_slug}. This link expires in 24 hours.'
- Return 200 { ok: true }.
2. In supabase/functions/verify-claim/index.ts (GET flow — confirming the token):
- Read token and listing slug from query params.
- Find listing WHERE confirmation_token = token AND is_deleted = false.
- If not found: return 404 { error: 'invalid_or_expired_token' }.
- Require auth header (the user must be signed in to claim): read auth.uid() from the JWT.
- UPDATE listings SET owner_id = auth_uid, claimed = true, confirmation_token = null WHERE id = listing.id.
- Upsert the user's profile and increment claimed_listings_count.
- Return 200 { ok: true, listing_slug }.
3. In ClaimVerify.tsx, on success redirect to /owner/dashboard with a toast 'Listing claimed successfully!'.When to use: Needed for any listing density above ~100 where you can't manually approve every claim
Add sponsored/featured listing upsell with Stripe Checkout
One-time $29 payment for 30-day featured placement — the primary monetization path for directory sites
Add a 'Feature My Listing' upsell for listing owners:
1. In owner/Dashboard.tsx, add a 'Feature' button next to each claimed listing. If listings.featured=true, show 'Featured until [date]' badge. If false, show 'Get Featured' button.
2. Create a listings_features table: id (uuid pk), listing_id (uuid fk listings), activated_at (timestamptz default now()), expires_at (timestamptz not null), stripe_session_id (text unique), status (text check in ('active','expired')). RLS: owner reads own via listing_id join.
3. Create supabase/functions/feature-checkout/index.ts: POST { listing_id }. Verify the listing belongs to auth.uid(). Create a Stripe Checkout Session: mode='payment', line_items=[{ price_data: { currency: 'usd', product_data: { name: 'Featured Listing — 30 days' }, unit_amount: 2900 }, quantity: 1 }], success_url, cancel_url, metadata: { listing_id }. Return { url }.
4. Create supabase/functions/feature-webhook/index.ts: raw-body Stripe webhook. On checkout.session.completed: INSERT INTO listings_features (listing_id, expires_at = now() + interval '30 days', stripe_session_id, status='active'). UPDATE listings SET featured=true WHERE id=listing_id. ON CONFLICT (stripe_session_id) DO NOTHING.
5. Create a pg_cron job to expire features: UPDATE listings SET featured=false WHERE id IN (SELECT listing_id FROM listings_features WHERE status='active' AND expires_at < now()); UPDATE listings_features SET status='expired' WHERE status='active' AND expires_at < now();
Use STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET from Deno.env.get().When to use: After you have at least 10 claimed listings and real organic traffic — featured upsell only works when there's competition for top placement
Add owner-only listing audit log
Immutable audit log of all owner listing edits, viewable by the owner in their edit page
Add change tracking for listing edits:
1. Create a listings_audit table: id (uuid pk default gen_random_uuid()), listing_id (uuid references listings), edited_by (uuid references auth.users), changed_fields (jsonb), previous_values (jsonb), edited_at (timestamptz default now()). RLS: FOR SELECT TO authenticated USING (EXISTS (SELECT 1 FROM listings l WHERE l.id = listings_audit.listing_id AND l.owner_id = auth.uid())). No client INSERT.
2. Create a AFTER UPDATE trigger on listings: for any UPDATE where auth.uid() is set, insert a row into listings_audit with the changed_fields (jsonb of field names that changed) and previous_values (jsonb of the before-values). Use the plpgsql NEW and OLD records to compare.
3. In owner/EditListing.tsx, add a 'Change History' section at the bottom of the edit page. Show the last 10 audit entries: field changed, previous value, date. Use supabase.from('listings_audit').select().eq('listing_id', id).order('edited_at', { ascending: false }).limit(10).
This gives you a paper trail when owners dispute 'I didn't change that' — and shows you who edited what if you later add team access.When to use: Once you have 10+ owners — disputes about listing content will happen and you need a record
Common errors
Real error strings you'll see. Find yours, paste the fix prompt.
new row violates row-level security policy for table "leads"The anon INSERT policy on leads is missing — Lovable's default scaffold may have added an authenticated-only INSERT, which blocks anonymous visitor form submissions.
In Cloud → Database → SQL Editor, add the explicit anon INSERT policy: CREATE POLICY anon_lead_submit ON leads FOR INSERT TO anon WITH CHECK (true); This allows any visitor to submit a lead. CRITICAL: do NOT also add a SELECT policy for anon. The SELECT policy must remain: FOR SELECT TO authenticated USING (EXISTS (SELECT 1 FROM listings l WHERE l.id = leads.listing_id AND l.owner_id = auth.uid())); Verify in an incognito window that supabase.from('leads').select() returns 0 rows (not an error, not any rows).anyone can read every lead via /rest/v1/leads (SELECT returns all rows for anon)Lovable scaffolded a SELECT policy for anon on the leads table when it added the INSERT policy. This is the most common directory RLS mistake — it exposes every visitor's contact information to anyone with the Supabase API URL.
Run in Cloud → Database → SQL Editor: SELECT policyname, cmd FROM pg_policies WHERE tablename = 'leads'; Drop any policy with cmd = 'SELECT' that includes anon in its roles: DROP POLICY [policy_name] ON leads; The ONLY SELECT policy allowed is one that limits to the listing owner via inner join: CREATE POLICY owner_reads_leads ON leads FOR SELECT TO authenticated USING (EXISTS (SELECT 1 FROM listings l WHERE l.id = leads.listing_id AND l.owner_id = auth.uid()));
no schema has been selected to create in (operator: tsquery)The pg_trgm extension was not enabled before the migration tried to create the tsvector column or use text-search operators.
Run in Cloud → Database → SQL Editor: CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE EXTENSION IF NOT EXISTS unaccent; Then rerun the migration or recreate the search_vector column: ALTER TABLE listings ADD COLUMN IF NOT EXISTS search_vector tsvector GENERATED ALWAYS AS (to_tsvector('english', name || ' ' || coalesce(description,''))) STORED; CREATE INDEX IF NOT EXISTS idx_listings_search ON listings USING GIN (search_vector);search returns 0 rows even though listings existThe search_vector column is GENERATED ALWAYS but was created after existing rows were inserted, so existing rows have search_vector = null and the @@ operator never matches them.
Trigger a backfill by touching every existing row: UPDATE listings SET name = name WHERE is_deleted = false; This causes Postgres to recompute the GENERATED ALWAYS column for every row. After this runs, search should return results. For new rows added after the migration, the column is automatically populated on insert.
claim verification email never arrivesRESEND_API_KEY is not set in Cloud → Secrets, the from-domain is not verified in Resend, or SITE_URL points to an empty string making the verification link unclickable.
Check three things: (1) Cloud → Secrets — verify RESEND_API_KEY starts with re_ and SITE_URL is your actual domain (no trailing slash). (2) Resend dashboard → Domains — your sending domain must show green SPF and DKIM status. Without DNS verification, Resend rejects or silently drops the email. (3) In the verify-claim edge function, log the Resend API response: const res = await fetch('https://api.resend.com/emails', ...); console.log(await res.json()); — check Cloud → Logs → Edge Functions for the logged response.Listing page is not indexed by Google — Search Console shows 'Discovered, not indexed'Lovable is a Vite SPA. When Googlebot fetches /listing/my-plumbing-company it receives an empty <div id="root"> and must execute JavaScript to see the content. Google does run JavaScript, but it deprioritizes JS-rendered content and your individual listing pages will rank poorly or not at all.
Manual fix
Two options: (1) Export to GitHub (Settings → Connectors → GitHub → Export), create a new Next.js project, port your components, and use getStaticProps with ISR for listing pages. (2) Add vite-plugin-ssg to your Lovable project via Dev Mode — this pre-renders all listing URLs at build time into static HTML. Both are multi-hour projects. If Google ranking of individual listing pages is your business model, make this decision before spending 80+ credits on this build.
Cost reality
What this build actually costs — no surprises on your card.
Recommended Lovable plan
Pro $25/mo — the SSR decision and claim flow iteration each need several credit cycles; Free's 30/mo cap runs out mid-build
Monthly run cost breakdown
~80–150 credits — starter ~40, leads RLS audit ~15, full-text search ~25, claim flow ~40, featured listings ~50; SSR migration is outside this range and is a separate project total| Item | Cost |
|---|---|
| Lovable Pro During build only — cancel after you stop iterating | $25/mo |
| Supabase (Lovable Cloud) Listings table stays under 500MB free tier until ~50K listings | $0 |
| Resend Free 3K emails/mo covers claim verifications + lead notifications for a starting directory | $0 |
| Google Maps (optional) Only if you add map view — free tier covers 28K map loads/mo | $0–$5+/1K |
| Custom domain Required for Resend domain verification + professional directory URL | ~$12/yr |
| Vercel Pro (if SSR migration) Commercial use on Next.js requires Vercel Pro — hobby tier only for personal projects | $20/mo |
Scaling notes: Supabase Free 500MB DB handles ~50K listings comfortably. Resend free tier breaks at ~3K emails/mo (claim + lead notification). The biggest hidden cost for SEO-driven directories is hosting after SSR migration — Vercel Pro at $20/mo becomes the baseline if you need ISR for listing pages.
Production checklist
Steps to take before you share the URL with real users.
Security
Verify leads RLS is INSERT-only for anon
Open browser console in incognito, run: await supabase.from('leads').select(); — must return 0 rows or RLS error, never any lead data. If rows appear, run the leads RLS hardening follow-up prompt immediately.
Add spam protection to lead form
At minimum, add a hidden honeypot field to LeadCaptureForm.tsx: an input with display:none and name='website'. In submit-lead edge function, if website field is not empty, return 200 silently (bots fill it, humans don't). Then add IP rate limiting: 3 leads per IP per hour.
SEO
Decide on SSR strategy before launch
If your traffic strategy depends on Google ranking listing pages: export to GitHub, create a Next.js project, port the listing detail page to use getStaticProps with ISR (revalidate: 3600), deploy to Vercel. If your directory is member-only or SEO-light, the Vite SPA is fine — skip this step.
Add meta tags to listing pages
Even on the Vite SPA, add react-helmet to ListingDetail.tsx: <Helmet><title>{listing.name} | Your Directory</title><meta name='description' content={listing.description?.slice(0, 155)} /></Helmet> — this helps social sharing even if Google ranking is limited.
Verify Resend sending domain
Resend dashboard → Domains → Add Domain → add SPF + DKIM DNS records at your registrar → wait for green status. Claim verification emails and lead notifications will go to spam or be rejected without this.
Data Seed
Import initial listings
Use Cloud → Database → Table Editor → listings → Insert rows, or write a CSV import script using the Supabase JS client with the service-role key. At least 25–50 listings in 3–5 categories are needed before the directory feels credible to visitors.
Frequently asked questions
Will my Lovable directory rank on Google?
Individual listing pages will have limited Google ranking. Lovable builds a Vite SPA — when Googlebot fetches /listing/my-business it receives an empty HTML shell and must execute JavaScript to see the listing content. Google does run JavaScript, but it deprioritizes dynamically-rendered content and your listing pages will typically rank below server-rendered competitors. The homepage and category pages may rank better since Google visits them more frequently. If Google ranking of individual listing pages is your primary traffic strategy, you must either export to GitHub and migrate the listing pages to Next.js with ISR, or add vite-plugin-ssg to pre-render listing HTML at build time. Neither is a single Lovable prompt — budget a few days for either migration.
What's the difference between a directory and a marketplace, and which should I build?
A directory lists businesses or entities that visitors browse and contact — there's no transaction inside the platform. A marketplace facilitates transactions between parties — buyers pay sellers through the platform and the platform takes a cut. Build a directory when your value is the listing data and lead routing. Build a marketplace when your value is the transaction guarantee and trusted payments. You can start as a directory and add marketplace features (featured placements → paid bookings → full payments) incrementally, which is exactly what the featured listing follow-up prompt enables.
How do I prevent spam in the lead-capture form?
The starter prompt includes basic IP rate-limiting in submit-lead (3 leads per IP per hour). Add two more layers: a honeypot field (a hidden input that bots fill but humans don't — reject any submission where it has a value) and Cloudflare Turnstile (free, privacy-respecting CAPTCHA alternative). Add Turnstile by adding the @marsidev/react-turnstile component to LeadCaptureForm.tsx and verifying the token server-side in submit-lead via fetch to https://challenges.cloudflare.com/turnstile/v0/siteverify. All three layers together (rate limit + honeypot + Turnstile) stop >99% of spam.
How does the claim-listing flow work, and can I trust it?
The claim flow sends a one-time token to an email address the claimant provides. Clicking the link in the email sets owner_id on the listing and marks it as claimed. The trust level is 'email ownership verification' — you're confirming the claimant has access to the provided email, not that they are the actual business owner. For higher-trust verification (actual business ownership), you'd need to add identity document upload or a phone call to the listed phone number. For most directories, email verification is sufficient to deter casual squatting while allowing legitimate owners to claim quickly.
Should I migrate to Next.js before launch, or just pre-render?
It depends on your traffic strategy and timeline. Pre-rendering with vite-plugin-ssg is faster (hours of work) and keeps everything in Lovable, but it is a one-time snapshot — new listings only appear in search after the next build. Next.js with ISR gives you fresh HTML for every listing on every request with a configurable revalidation window, which is better for a growing directory, but migration takes a few days. If your directory has fewer than 500 listings and grows slowly, pre-rendering is fine. If you plan to have thousands of listings with daily additions and organic SEO is the business, migrate to Next.js before launch.
Can I monetize with sponsored/featured listings?
Yes — the featured listing follow-up prompt adds a Stripe Checkout flow that charges listing owners $29 for 30 days of featured placement. Featured listings appear at the top of category pages and have a 'Featured' badge. The revenue model is: seed listings for free, get traffic, then upsell featured placement to the listings that benefit most from visibility. This is how Clutch.co, G2, and Yelp all monetize — the listings themselves are free, but prominent placement is paid.
How does Postgres full-text search compare to Algolia for a directory of 10K listings?
For up to ~50K listings, Postgres tsvector full-text search (what this prompt uses) is more than adequate — ranked results, phrase matching, accent-insensitive search, and a GIN index for sub-millisecond queries. Algolia becomes worth $50–$250/mo when you need: instant-as-you-type results without an edge function round-trip, faceted filtering (filter by category + location + rating simultaneously), or typo-tolerance beyond what pg_trgm provides. For a typical niche directory with 1K–10K listings, the Postgres full-text search in this prompt is the right choice. If your search-listings edge function starts taking longer than 200ms, consider adding a partial GIN index on the categories you filter by most frequently. If your build outgrows this prompt kit and you need custom architecture, RapidDev builds production-grade Lovable apps at $13K-$25K — book a free 30-minute consultation at rapidevelopers.com.
Need a production-grade version?
RapidDev builds production-grade Lovable apps at $13K–$25K. Book a free 30-minute consultation.
Book a free consultation30-min call. No commitment.