TL;DR
The one-paragraph version before you dive in.
This prompt kit builds a Craigslist-style classifieds site where users post sell or wanted ads with up to six photos, browse by location and category, contact posters via an anonymous inquiry form, and ads expire automatically after 30 days. The biggest risk is spam — the second prompt in this chain is dedicated to anti-spam hardening (honeypot, IP rate limit, Cloudflare Turnstile, email confirmation), not the fifth. Ship this in a day with Lovable Pro.
Setup checklist
Complete these steps in Lovable before pasting the starter prompt — takes about 5 minutes.
Cloud tab settings
Database
Stores ads, ad_images, categories, inquiries, reports, and profiles. RLS enforces that only active non-expired ads are public-readable, and that inquiries are INSERT-only for anonymous visitors — no SELECT for anon.
- 1Click the + icon next to the preview panel, then click 'Cloud tab' → 'Database'.
- 2Lovable Cloud provisions Supabase Postgres automatically.
- 3The starter prompt will run the migration. If it doesn't auto-run, open Cloud → Database → SQL Editor and paste the migration SQL manually.
- 4After migration, run: `CREATE EXTENSION IF NOT EXISTS pg_cron;` and then the expire-ads cron job (shown in the follow-up prompt).
Auth
Email/password sign-in for registered posters. Anonymous posters use the email-confirmation flow instead. Admin accounts use has_role('admin') for the moderation queue.
- 1In Cloud tab → 'Users & Auth', confirm Email provider is enabled.
- 2Enable email confirmation in Auth settings if you want to gate anonymous posts behind email verification.
- 3After the starter runs, sign up with your email and set your role: `UPDATE profiles SET role = 'admin' WHERE id = (SELECT id FROM auth.users WHERE email = 'your@email.com');`
Storage
Each ad can have up to 6 photos stored in the 'ad-images' bucket. Images are public-read; only authenticated or confirmed-poster users can upload.
- 1In Cloud tab → 'Storage', create a bucket named 'ad-images' and set it to Public.
- 2Add Storage policies: INSERT for authenticated users, public SELECT for all.
- 3Client-side image compression must be added (see starter prompt) to keep files under 5MB — the Storage default upload limit.
Edge Functions
Four edge functions handle: submit-ad (anti-spam + email confirmation), submit-inquiry (rate-limit + Turnstile verify + INSERT), expire-ads (pg_cron scheduled), confirm-ad (token verification → status='active').
- 1After the starter prompt runs, open Cloud tab → 'Edge Functions' to verify all four functions were deployed.
- 2If any function is missing, paste the follow-up prompt for that function.
- 3Set SITE_URL in Cloud → Secrets so confirmation email links point to your domain (e.g. https://yourclassifieds.com).
Secrets (Cloud tab → Secrets)
RESEND_API_KEYPurpose: Sends ad confirmation emails and inquiry forwarding to poster email addresses.
Where to get it: Sign up at resend.com, go to API Keys → Create API Key. Free tier: 3K emails/mo.
TURNSTILE_SECRET_KEYPurpose: Server-side verification of Cloudflare Turnstile CAPTCHA on post and inquiry forms — blocks bots.
Where to get it: Go to dash.cloudflare.com → Turnstile → Add site. The secret key is shown after site creation. Turnstile is free.
SITE_URLPurpose: Used in edge functions to build confirmation email links like https://SITE_URL/confirm/TOKEN.
Where to get it: Your published Lovable domain or custom domain, e.g. https://yourclassifieds.com
OPENAI_API_KEYPurpose: Content moderation API call in submit-ad edge function to flag prohibited content before making ads active.
Where to get it: platform.openai.com → API Keys → Create new secret key. Optional — only needed for the moderation follow-up prompt.
Preflight checklist
- You are in a fresh Lovable project (Vite + React + TypeScript + Tailwind + shadcn/ui already scaffolded).
- You have decided whether anonymous users can post without signing up — this determines whether you use the email-confirmation flow in submit-ad. The starter prompt includes both paths.
- This is a cousin-substitution category: no exact Lovable classifieds build is documented in 2026 research. Credit estimates (~100-180 credits) are extrapolated from directory + job-board patterns. Use ranges in your planning, not fixed numbers.
- Spam will arrive within hours of launch without anti-spam hardening — make the second follow-up prompt (anti-spam) your immediate priority after the starter.
The starter prompt
Copy this. Paste it into Lovable Build mode (the default chat at the bottom-left of the editor). Hit send.
Build a Craigslist-style classifieds site using Vite + React + TypeScript + Tailwind CSS + shadcn/ui, backed by Supabase (Lovable Cloud). Here is the exact schema, RLS, routes, and components to scaffold:
**Database migration (0001_classifieds_schema.sql):**
Create six tables:
1. `profiles` — id uuid references auth.users pk, full_name text, email text, role text default 'user'. RLS: per-user write own.
2. `categories` — id uuid pk, slug text unique, name text, parent_id uuid nullable references categories. RLS: public-read, admin-write via has_role().
3. `ads` — id uuid pk, slug text unique, poster_id uuid nullable references auth.users, poster_email text not null, poster_name text, title text not null, description text, price_cents int, category_id uuid references categories, location text, lat float, lng float, status text check (status in ('pending_review','active','sold','expired','flagged','deleted')) default 'pending_review', posted_at timestamptz default now(), expires_at timestamptz default (now() + interval '30 days'), confirmation_token text unique, search_vector tsvector generated always as (to_tsvector('english', title || ' ' || coalesce(description, ''))) stored. RLS: public-read where `status = 'active' AND expires_at > now()`, per-poster write where `poster_id = auth.uid()`, admin-write via has_role('admin').
4. `ad_images` — id uuid pk, ad_id uuid references ads, image_path text not null, position int default 0. RLS: inherits from ad (public-read active ads, per-poster write).
5. `inquiries` — id uuid pk, ad_id uuid references ads, from_email text not null, from_name text, message text not null, sent_at timestamptz default now(), ip_address text, user_agent text. RLS: **anon INSERT only WITH CHECK (true), NO SELECT for anon.** Authenticated SELECT only where the inquiry's ad_id is owned by auth.uid() (via inner join to ads where ads.poster_id = auth.uid()). Admin SELECT all.
6. `reports` — id uuid pk, ad_id uuid references ads, reporter_id uuid nullable, reason text check (reason in ('spam','scam','prohibited','duplicate','other')), notes text, created_at timestamptz default now(). RLS: anon + authenticated INSERT only, admin SELECT.
Create the has_role() SECURITY DEFINER plpgsql function:
```sql
CREATE OR REPLACE FUNCTION has_role(role_name text) RETURNS boolean
LANGUAGE plpgsql SECURITY DEFINER AS $$
BEGIN
RETURN (SELECT role FROM public.profiles WHERE id = auth.uid()) = role_name;
END;
$$;
```
Add: GIN index on ads.search_vector, partial index on ads(posted_at DESC) where status='active', pg_cron extension enabled.
**Layouts:**
- `PublicLayout.tsx` — top nav with Logo / Post Ad (CTA button) / Sign in / Search bar.
- `AdminLayout.tsx` — sidebar with Pending / Active / Flagged / Categories sections. Wrap in AdminGuard.
**Pages and routes:**
- `/` — latest active ads grid by location, with category nav and featured ads.
- `/c/[category-slug]` — filtered ad grid with price and location filter chips.
- `/search?q=&location=` — full-text search results using Postgres tsvector query.
- `/ad/[slug]` — ad detail with AdGallery (image carousel), description, price, location, inquiry form, and Report flag.
- `/post` — multi-step posting form: (1) category + title + description + price, (2) location, (3) photo upload (up to 6, with client-side compression via browser-image-compression targeting 1024px max, ~200KB each), (4) contact email + name + preview → submit.
- `/confirm/[token]` — email confirmation page that calls confirm-ad edge function; on success shows 'Your ad is live' and a link to /ad/[slug].
- `/my-ads` — signed-in poster's dashboard showing all their ads with status chips, Edit, Mark as Sold, and Delete actions.
- `/admin/moderation` — admin-only queue of pending_review and flagged ads with Approve, Flag, Delete bulk actions.
- `/signin` — Supabase Auth form.
**Edge Functions:**
- `submit-ad` — receives posting form data, validates required fields, generates confirmation_token (crypto.randomUUID()), inserts ad with status='pending_review', sends confirmation email via Resend with link `${SITE_URL}/confirm/${token}`. Signed-in users skip confirmation and go straight to status='active'.
- `submit-inquiry` — receives from_email, from_name, message, ad_id. Validates ad is active. Inserts into inquiries. Sends inquiry via Resend to the poster's poster_email.
- `confirm-ad` — looks up the token, sets status='active' on the ad, returns ad slug for redirect.
- `expire-ads` — called by pg_cron hourly: `UPDATE ads SET status='expired' WHERE status='active' AND expires_at <= now();`
**Key components:**
- `AdCard.tsx` — thumbnail image, title, price, location, posted_at date, category.
- `AdGallery.tsx` — shadcn Carousel of up to 6 images.
- `PostForm.tsx` — multi-step with progress indicator; step 3 includes image upload with preview grid.
- `InquiryForm.tsx` — text area + from_email + from_name. Include a hidden honeypot input (display:none, if non-empty, silently drop the submission).
- `LocationFilter.tsx` — text input that filters ads by location field.
- `CategoryNav.tsx` — horizontal scrollable list of category pills.
**Styling:** utilitarian classifieds feel — clean list-first layout, prominent search bar, slate background; shadcn Card, Carousel, Form, Select, Badge for status chips.
**Deliverables:** schema migration with INSERT-only anon RLS on inquiries and reports, browse + search + ad detail, multi-step post form with photo upload, email confirmation flow, expire-ads scheduled function, and basic admin moderation queue.What this prompt generates
- Generates the full migration with 6 tables, RLS with INSERT-only anon policies on inquiries and reports, and the has_role() SECURITY DEFINER function.
- Scaffolds 4 edge functions: submit-ad (with email confirmation), submit-inquiry (with honeypot), confirm-ad (token activation), expire-ads (pg_cron).
- Creates the public browse, search, and ad-detail pages with an image carousel.
- Builds the multi-step posting form with client-side image compression and 6-image upload.
- Creates the signed-in poster dashboard (/my-ads) and admin moderation queue.
- Wires Resend for confirmation and inquiry-forwarding emails.
Paste into: Lovable Build mode (the default chat at the bottom-left of the editor)
Expected output
What Lovable will generate after the starter prompt runs successfully.
Files
src/layouts/PublicLayout.tsxTop nav with Search, Post Ad CTA, Sign in
src/layouts/AdminLayout.tsxSidebar for moderation queue with AdminGuard
src/pages/Home.tsxLatest active ads grid with category nav
src/pages/Category.tsxCategory-filtered ad grid with filters
src/pages/Search.tsxFull-text search results page
src/pages/AdDetail.tsxAd detail with gallery, inquiry form, and Report flag
src/pages/Post.tsxMulti-step ad posting form with image upload
src/pages/Confirm.tsxEmail confirmation landing page
src/pages/MyAds.tsxPoster's dashboard — own ads with status and actions
src/pages/admin/Moderation.tsxAdmin queue for pending and flagged ads
src/components/AdCard.tsxAd thumbnail card
src/components/AdGallery.tsxshadcn Carousel for up to 6 ad images
src/components/PostForm.tsxMulti-step posting form with image upload
src/components/InquiryForm.tsxInquiry form with honeypot hidden field
src/components/LocationFilter.tsxLocation text filter chip
src/components/CategoryNav.tsxHorizontal scrollable category pills
supabase/functions/submit-ad/index.tsAd submission + email confirmation edge function
supabase/functions/submit-inquiry/index.tsInquiry submission + Resend forwarding edge function
supabase/functions/confirm-ad/index.tsToken confirmation → status='active' edge function
supabase/functions/expire-ads/index.tspg_cron target: expires stale ads
supabase/migrations/0001_classifieds_schema.sqlAll 6 tables + RLS + has_role() + indexes + pg_cron
Routes
Homepage — latest active ads grid
Category-filtered ad grid
Full-text search results
Ad detail with gallery and inquiry form
Multi-step ad posting form
Email confirmation → ad activation
Poster dashboard — own ads
Admin moderation queue
Auth form
DB Tables
ads| Column | Type |
|---|---|
| id | uuid primary key |
| slug | text unique |
| poster_id | uuid nullable references auth.users |
| poster_email | text not null |
| title | text not null |
| description | text |
| price_cents | int |
| category_id | uuid references categories |
| location | text |
| status | text check in ('pending_review','active','sold','expired','flagged','deleted') |
| expires_at | timestamptz default (now() + interval '30 days') |
| confirmation_token | text unique |
| search_vector | tsvector generated always as stored |
RLS: Public-read where status='active' AND expires_at > now(). Per-poster write via poster_id=auth.uid(). Admin-write via has_role('admin').
inquiries| Column | Type |
|---|---|
| id | uuid primary key |
| ad_id | uuid references ads |
| from_email | text not null |
| from_name | text |
| message | text not null |
| ip_address | text |
RLS: Anon INSERT only — WITH CHECK (true). NO SELECT for anon. Authenticated SELECT only via inner join to ads where ads.poster_id = auth.uid().
reports| Column | Type |
|---|---|
| id | uuid primary key |
| ad_id | uuid references ads |
| reason | text check in ('spam','scam','prohibited','duplicate','other') |
RLS: Anon + authenticated INSERT only. Admin SELECT all.
ad_images| Column | Type |
|---|---|
| id | uuid primary key |
| ad_id | uuid references ads |
| image_path | text not null |
| position | int default 0 |
RLS: Inherits from parent ad: public-read when ad is active; per-poster write.
categories| Column | Type |
|---|---|
| id | uuid primary key |
| slug | text unique |
| name | text |
| parent_id | uuid nullable references categories |
RLS: Public-read. Admin-write via has_role('admin').
profiles| Column | Type |
|---|---|
| id | uuid primary key references auth.users |
| full_name | text |
| text | |
| role | text default 'user' |
RLS: Per-user write own. Admin reads all.
Components
AdCardThumbnail + title + price + location for browse grids
AdGalleryshadcn Carousel for up to 6 ad images
PostFormMulti-step posting form with compressed image upload
InquiryFormInquiry form with hidden honeypot field
LocationFilterText input filter for location field
CategoryNavScrollable horizontal category pills
AdminGuardProtects /admin/* via has_role('admin')
Follow-up prompts
Paste these into Agent Mode one by one, in order, after the starter prompt finishes.
Anti-spam hardening: honeypot + IP rate limit + Cloudflare Turnstile
Honeypot + IP rate limit + Cloudflare Turnstile + email confirmation gate — four spam layers
Add four layers of anti-spam to the submit-inquiry and submit-ad edge functions. Paste this right after the starter finishes:
1. **Honeypot field:** In InquiryForm.tsx and PostForm.tsx, add a hidden text input with name='website' and style `display:none; position:absolute; opacity:0;`. In submit-inquiry edge function, if the `website` field is non-empty, return 200 silently without inserting — bots fill hidden fields, humans don't.
2. **IP rate limit:** In submit-inquiry, read `req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for')`. Check a simple in-memory counter using Supabase `supabase.from('ip_rate_limits').upsert({ip, count: 1, window_start: now})`. If count > 3 in the last 60 minutes for this IP, return HTTP 429 with `{ error: 'Too many inquiries. Try again later.' }`.
3. **Cloudflare Turnstile:** In InquiryForm.tsx, render the Turnstile widget: `<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer />` and a `<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" />`. Read the `cf-turnstile-response` token from the form. In submit-inquiry edge function, verify it: `const result = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', body: JSON.stringify({ secret: Deno.env.get('TURNSTILE_SECRET_KEY'), response: token }) })`. If success is false, return 403.
4. **Email confirmation gate on submit-ad:** submit-ad must set status='pending_review' (not 'active') and email a confirmation link. Add this check so posting without clicking the email link never creates an active ad.
Test: submit a form from an incognito browser without filling the Turnstile. You should see HTTP 403.When to use: Paste immediately after the starter prompt — spam will arrive within hours of launch without this
Anonymous-insert RLS audit and lockdown
Verifies inquiries and reports tables have INSERT-only anon RLS with no SELECT leakage
Explicitly verify and harden the RLS policies on the inquiries and reports tables. Paste this prompt as a verification step:
In Cloud → Database → SQL Editor, run the following to confirm the policies:
```sql
SELECT tablename, policyname, cmd, roles, qual, with_check
FROM pg_policies
WHERE tablename IN ('inquiries', 'reports');
```
You should see INSERT-only policies for anon on both tables, and NO SELECT policy for anon on either. If a SELECT policy for anon exists on inquiries, drop it immediately:
```sql
DROP POLICY IF EXISTS inquiries_anon_select ON inquiries;
```
Then verify in an incognito browser using the browser console:
```javascript
const { data, error } = await supabase.from('inquiries').select();
console.log(data, error); // should return 0 rows or RLS error
```
If data is non-empty (you can see inquiries), the SELECT policy for anon is still active. Drop it and add only: `CREATE POLICY inquiries_owner_select ON inquiries FOR SELECT TO authenticated USING (EXISTS (SELECT 1 FROM ads WHERE ads.id = inquiries.ad_id AND ads.poster_id = auth.uid()));`
Repeat the incognito test after fixing. Confirm 0 rows returned for anon.When to use: After the anti-spam prompt — run this before any public beta to confirm inquiries are not readable by strangers
Scheduled expiry via pg_cron
pg_cron hourly job that expires stale ads and optional 3-day warning email
Set up automatic ad expiry so ads that have passed their 30-day window are automatically moved to status='expired'. Two steps:
1. Enable pg_cron in Cloud → Database → SQL Editor:
```sql
CREATE EXTENSION IF NOT EXISTS pg_cron;
```
2. Schedule the expire-ads job to run every hour:
```sql
SELECT cron.schedule(
'expire-ads',
'0 * * * *',
$$ UPDATE ads SET status='expired' WHERE status='active' AND expires_at <= now(); $$
);
```
3. Also add a 3-day warning job that sends an email to poster_email when their ad expires in 3 days:
```sql
SELECT cron.schedule(
'expire-ads-warning',
'0 9 * * *',
$$ SELECT net.http_post(url := (SELECT value FROM app_settings WHERE key = 'edge_function_url') || '/functions/v1/send-expiry-warnings', headers := '{"Authorization": "Bearer SERVICE_ROLE_KEY"}'::jsonb); $$
);
```
For the simpler version without the warning email, just run the first two SQL statements. Verify by updating one ad's expires_at to 5 minutes ago and confirming it moves to status='expired' within the hour.When to use: After anti-spam hardening — without expiry, stale ads accumulate and the homepage becomes a graveyard
OpenAI content moderation on ad submit
OpenAI moderation API check on submit to block obviously prohibited content
Add an OpenAI moderation pass to the submit-ad edge function to flag obviously prohibited content (hate speech, sexual content, violence, self-harm) before an ad becomes active.
In submit-ad/index.ts, after the initial validation and before the database insert, add:
```typescript
const openaiKey = Deno.env.get('OPENAI_API_KEY');
if (openaiKey) {
const modResponse = await fetch('https://api.openai.com/v1/moderations', {
method: 'POST',
headers: { 'Authorization': `Bearer ${openaiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ input: `${title} ${description}` })
});
const modResult = await modResponse.json();
const flagged = modResult.results?.[0]?.flagged;
if (flagged) {
return new Response(JSON.stringify({ ok: false, reason: 'content_policy' }), { status: 422 });
}
}
```
Add OPENAI_API_KEY to Cloud → Secrets (not as VITE_OPENAI_KEY — never client-side). Cost: ~$0.0001 per ad submitted, negligible. If the OpenAI call fails (timeout, outage), log the error but DO NOT block the submission — content moderation failure should not prevent legitimate ads from posting.When to use: After anti-spam and expiry prompts — adds a content policy layer for community safety
Featured/bumped ads with Stripe Checkout
Stripe-powered $5 ad bump to top of category for 7 days — primary monetization path
Add a 'Bump your ad to the top' feature where posters pay $5 to feature their ad at the top of its category for 7 days.
1. Add columns to ads: `featured bool default false`, `featured_until timestamptz nullable`, `stripe_session_id text unique nullable`.
2. Create a `create-bump-session` edge function that creates a Stripe Checkout Session:
```typescript
const session = await stripe.checkout.sessions.create({
payment_method_types: [],
automatic_payment_methods: { enabled: true },
line_items: [{ price_data: { currency: 'usd', product_data: { name: 'Ad Bump — 7 days featured' }, unit_amount: 500 }, quantity: 1 }],
mode: 'payment',
success_url: `${Deno.env.get('SITE_URL')}/ad/${adSlug}?bumped=1`,
cancel_url: `${Deno.env.get('SITE_URL')}/ad/${adSlug}`,
metadata: { ad_id: adId }
});
```
3. Create a `stripe-bump-webhook` edge function. Read raw body with `await req.text()`, verify with `await stripe.webhooks.constructEventAsync(rawBody, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET'))`. On checkout.session.completed, update: `UPDATE ads SET featured=true, featured_until=now()+interval '7 days' WHERE id=metadata.ad_id`.
Add STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET to Cloud → Secrets. Test with Stripe test card 4242 4242 4242 4242 on a published (not preview) URL.When to use: After the core build is stable — this is the monetization layer; ship the free site first
Common errors
Real error strings you'll see. Find yours, paste the fix prompt.
new row violates row-level security policy for table "inquiries"Default scaffold granted INSERT only to authenticated users — but inquiry forms are submitted by anonymous visitors browsing ads without accounts.
Add explicit anon INSERT policy in Cloud → Database → SQL Editor: `CREATE POLICY anon_inquiry_submit ON inquiries FOR INSERT TO anon WITH CHECK (true);` Critically, do NOT add any SELECT policy for anon. Verify with: open incognito browser → console → `supabase.from('inquiries').select()` → should return 0 rows or RLS error.anyone can read every inquiry via /rest/v1/inquiriesLovable scaffolded a SELECT policy for anon along with the INSERT policy — this exposes every inquiry (including email addresses) to anyone who knows your Supabase URL.
Run in SQL Editor: `DROP POLICY IF EXISTS inquiries_public_select ON inquiries;` Then add the correct authenticated-only policy: `CREATE POLICY inquiries_poster_select ON inquiries FOR SELECT TO authenticated USING (EXISTS (SELECT 1 FROM ads WHERE ads.id = inquiries.ad_id AND ads.poster_id = auth.uid()));` Verify in incognito: supabase.from('inquiries').select() must return 0 rows.scheduled expire-ads function never runspg_cron extension is not enabled by default in Lovable Cloud, so the expire-ads edge function or SQL job is set up but never invoked.
Run in Cloud → Database → SQL Editor: `CREATE EXTENSION IF NOT EXISTS pg_cron; SELECT cron.schedule('expire-ads', '0 * * * *', $$ UPDATE ads SET status='expired' WHERE status='active' AND expires_at <= now(); $$);` Verify by setting one ad's expires_at to 5 minutes ago and confirming it changes to status='expired' after the next hourly run.image upload returns 413 Payload Too LargeLovable users uploaded a 10-12MB phone photo directly; default Supabase Storage upload limit is 5MB.
Add client-side compression to PostForm.tsx before the upload call: install browser-image-compression, then `const compressed = await imageCompression(file, { maxSizeMB: 0.2, maxWidthOrHeight: 1024 })`. Always pass `compressed` to supabase.storage.from('ad-images').upload(). This keeps files under 200KB per image.Email confirmation link sends user to undefined/confirm/nullThe confirmation_token was not generated before the insert, or SITE_URL was not set in Cloud → Secrets, so the URL template resolves as 'undefined/confirm/...'.
In submit-ad edge function, generate the token before the insert: `const token = crypto.randomUUID();` Include it in the INSERT: `confirmation_token: token`. Build the URL: `` `${Deno.env.get('SITE_URL')}/confirm/${token}` ``. Confirm SITE_URL is set in Cloud → Secrets (not as a VITE_ prefix). Test by posting a new ad and clicking the received email link.OpenAI moderation API returns 401 UnauthorizedOPENAI_API_KEY is not set in Cloud → Secrets, or it was accidentally set as VITE_OPENAI_KEY which is a client-side build variable and not available inside Deno edge functions.
Open Cloud → Secrets (not Environment Variables). Add OPENAI_API_KEY without any VITE_ prefix. Access inside the edge function only via `Deno.env.get('OPENAI_API_KEY')`. Never reference this key in any /src/ file — a VITE_ prefix bakes the key into your production JS bundle where bots can scrape it.Cost reality
What this build actually costs — no surprises on your card.
Recommended Lovable plan
Pro $25/mo recommended — the anti-spam hardening and email-confirmation iteration will burn through Free's daily limit by the second round of fixes. This is a cousin-substitution category so expect a few extra iteration rounds.
Monthly run cost breakdown
~100-180 credits — extrapolated from directory + job-board patterns (no documented exact Lovable classifieds build in 2026 research). Breakdown: starter ~40, anti-spam ~40, email confirmation ~30, expiry cron ~30, moderation queue ~40. Use ranges in planning. total| Item | Cost |
|---|---|
| Lovable Pro (build phase) Stop billing after the build is complete | $25/mo |
| Supabase Ads table stays small with 30-day expiry rotation; even 200K lifetime ads under 500MB DB | Free $0 |
| Resend One confirmation + one inquiry forward per ad/inquiry; breaks at ~1.5K ads+inquiries/mo | Free $0 (up to 3K emails/mo) |
| OpenAI moderation At 1K ad submissions/mo, that's $0.10/mo total | ~$0.0001/call, negligible |
| Cloudflare Turnstile Turnstile has no usage-based fee | Free |
| Custom domain Optional — works on lovable.app subdomain | ~$12/yr |
Scaling notes: Resend free tier breaks first at ~1.5K combined ads and inquiries per month — bump to Resend Pro $20/mo. Supabase Free 500MB handles ~200K total ads with 30-day rotation. The biggest hidden cost is human moderation time once volume picks up — budget for a part-time moderator before launch.
Production checklist
Steps to take before you share the URL with real users.
Domain & SSL
Connect your custom domain
Click Publish (top-right) → Settings → Custom Domain → enter domain → follow DNS instructions. SSL is automatic. Update SITE_URL in Cloud → Secrets to your new production URL.
Anti-Spam
Verify all four spam layers are active before any public announcement
Open an incognito browser and: (1) submit an inquiry — confirm Turnstile is shown and required, (2) fill in the honeypot field manually and submit — confirm the server silently drops it, (3) submit 4 inquiries in quick succession from the same IP — confirm the 4th gets HTTP 429.
Verify Resend sending domain before launch
In Resend dashboard → Domains → Add Domain → add SPF, DKIM, and DMARC records to your DNS provider. Resend shows a green status when verified. Without domain verification, confirmation emails may land in spam or not deliver.
Monitoring
Monitor Cloud → Logs for edge function errors daily
Click + → Cloud tab → Logs → filter by function name. Set up a Slack or email alert via submit-ad to notify you when any edge function returns a 5xx error. Without monitoring, you won't know if confirmation emails stop sending.
Frequently asked questions
Can anonymous users post and inquire without signing up?
Yes, with the email-confirmation flow. Anonymous posters submit the posting form, receive a confirmation email, click the link to activate their ad, and never need an account. Signed-in users skip the confirmation step. Inquiries are always anonymous — from_email is collected for forwarding, but submitters never create an account. This design is intentional: requiring signup to post or inquire kills conversion for classifieds.
How do I stop spam from killing my classifieds site on day one?
Four layers, applied in the second prompt in this chain: (1) Honeypot field — a hidden input that humans ignore but bots fill; submissions with a non-empty honeypot are silently dropped. (2) IP rate limit — maximum 3 inquiries per hour per IP address, enforced in the edge function. (3) Cloudflare Turnstile — a bot-proof CAPTCHA with near-zero friction for humans. (4) Email confirmation gate — ads only go active after the poster clicks a confirmation link, making bot-mass-posting expensive. None of these are optional — without all four, expect hundreds of spam ads within 24 hours of launch.
Why does the inquiry form expose all other inquiries to anyone?
This is a misconfigured RLS policy. Lovable's default scaffold sometimes grants both INSERT and SELECT to the anon role on the same table when you ask for anonymous submission. On an inquiries table, this means anyone can call `supabase.from('inquiries').select()` and see every inquiry ever sent — including email addresses. The correct policy is INSERT-only for anon, and SELECT only for authenticated users whose ads.poster_id matches auth.uid(). Run the anonymous-insert RLS audit follow-up prompt immediately after the starter.
How do ad expiry and the 30-day clock work technically?
Each ad row has an expires_at column defaulting to now() + interval '30 days'. A pg_cron job runs hourly and updates status to 'expired' for any active ad where now() > expires_at. The public-read RLS policy additionally filters on expires_at > now() so even if the cron job is delayed, expired ads won't appear in browse results. You can extend or reset expiry by updating the expires_at column — the poster dashboard includes an 'Renew for 30 days' action.
Can I monetize with featured or bumped ads via Stripe?
Yes. The 'Featured/bumped ads with Stripe Checkout' follow-up prompt adds this. Posters pay $5 for a 7-day bump to the top of their category. The implementation uses standard Stripe Checkout (not Connect, since there's only one seller — you). The webhook sets featured=true and featured_until=now()+7 days on the ad. Browse queries order by featured DESC, posted_at DESC so featured ads appear first. This is the primary revenue model for most independent classifieds operators.
How is this different from a marketplace or directory?
Classifieds: anonymous posters, ephemeral ads (30-day expiry), no payment integration — buyers and sellers connect directly and transact offline. Directory: owned, permanent listings with a claim flow, no transactions, lead capture forms. Marketplace: integrated Stripe payments, buyer protection, seller accounts with ratings. Build classifieds when the user experience is 'post an ad and wait for texts'; build a marketplace when you need escrow or payment processing in the app.
What happens if someone reports an ad as a scam?
The Report button on each ad inserts a row into the reports table with the ad_id and reason. This is INSERT-only for all users — no one can read the reports table except admins. The admin moderation queue at /admin/moderation shows all flagged ads grouped by report count. Admins can approve (set status='active'), flag (set status='flagged' — removes from public browse but doesn't delete), or delete. You can add an automatic threshold: if an ad accumulates 3 reports, automatically set status='flagged' for admin review.
If your classifieds site needs spam protection at scale or custom moderation workflows, what does RapidDev offer?
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.