Skip to main content
RapidDev - Software Development Agency
Lovable PromptsMarketplaceIntermediate

Lovable Prompts for Building a Classifieds Site

A Craigslist-style classifieds site with anonymous ad posting via email confirmation, photo upload, Postgres full-text search, inquiry form with anti-spam hardening, and a pg_cron 30-day expiry job.

Time to MVP

~1 day

Credits

~100-180 credits for full chain

Difficulty

Intermediate

Cloud features

4

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.

  1. 1Click the + icon next to the preview panel, then click 'Cloud tab' → 'Database'.
  2. 2Lovable Cloud provisions Supabase Postgres automatically.
  3. 3The starter prompt will run the migration. If it doesn't auto-run, open Cloud → Database → SQL Editor and paste the migration SQL manually.
  4. 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.

  1. 1In Cloud tab → 'Users & Auth', confirm Email provider is enabled.
  2. 2Enable email confirmation in Auth settings if you want to gate anonymous posts behind email verification.
  3. 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.

  1. 1In Cloud tab → 'Storage', create a bucket named 'ad-images' and set it to Public.
  2. 2Add Storage policies: INSERT for authenticated users, public SELECT for all.
  3. 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').

  1. 1After the starter prompt runs, open Cloud tab → 'Edge Functions' to verify all four functions were deployed.
  2. 2If any function is missing, paste the follow-up prompt for that function.
  3. 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_KEY

Purpose: 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_KEY

Purpose: 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_URL

Purpose: 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_KEY

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

lovable-agent-mode.txt
~35-50 credits
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.tsx

Top nav with Search, Post Ad CTA, Sign in

src/layouts/AdminLayout.tsx

Sidebar for moderation queue with AdminGuard

src/pages/Home.tsx

Latest active ads grid with category nav

src/pages/Category.tsx

Category-filtered ad grid with filters

src/pages/Search.tsx

Full-text search results page

src/pages/AdDetail.tsx

Ad detail with gallery, inquiry form, and Report flag

src/pages/Post.tsx

Multi-step ad posting form with image upload

src/pages/Confirm.tsx

Email confirmation landing page

src/pages/MyAds.tsx

Poster's dashboard — own ads with status and actions

src/pages/admin/Moderation.tsx

Admin queue for pending and flagged ads

src/components/AdCard.tsx

Ad thumbnail card

src/components/AdGallery.tsx

shadcn Carousel for up to 6 ad images

src/components/PostForm.tsx

Multi-step posting form with image upload

src/components/InquiryForm.tsx

Inquiry form with honeypot hidden field

src/components/LocationFilter.tsx

Location text filter chip

src/components/CategoryNav.tsx

Horizontal scrollable category pills

supabase/functions/submit-ad/index.ts

Ad submission + email confirmation edge function

supabase/functions/submit-inquiry/index.ts

Inquiry submission + Resend forwarding edge function

supabase/functions/confirm-ad/index.ts

Token confirmation → status='active' edge function

supabase/functions/expire-ads/index.ts

pg_cron target: expires stale ads

supabase/migrations/0001_classifieds_schema.sql

All 6 tables + RLS + has_role() + indexes + pg_cron

Routes
/

Homepage — latest active ads grid

/c/[category-slug]

Category-filtered ad grid

/search

Full-text search results

/ad/[slug]

Ad detail with gallery and inquiry form

/post

Multi-step ad posting form

/confirm/[token]

Email confirmation → ad activation

/my-ads

Poster dashboard — own ads

/admin/moderation

Admin moderation queue

/signin

Auth form

DB Tables
ads
ColumnType
iduuid primary key
slugtext unique
poster_iduuid nullable references auth.users
poster_emailtext not null
titletext not null
descriptiontext
price_centsint
category_iduuid references categories
locationtext
statustext check in ('pending_review','active','sold','expired','flagged','deleted')
expires_attimestamptz default (now() + interval '30 days')
confirmation_tokentext unique
search_vectortsvector 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
ColumnType
iduuid primary key
ad_iduuid references ads
from_emailtext not null
from_nametext
messagetext not null
ip_addresstext

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
ColumnType
iduuid primary key
ad_iduuid references ads
reasontext check in ('spam','scam','prohibited','duplicate','other')

RLS: Anon + authenticated INSERT only. Admin SELECT all.

ad_images
ColumnType
iduuid primary key
ad_iduuid references ads
image_pathtext not null
positionint default 0

RLS: Inherits from parent ad: public-read when ad is active; per-poster write.

categories
ColumnType
iduuid primary key
slugtext unique
nametext
parent_iduuid nullable references categories

RLS: Public-read. Admin-write via has_role('admin').

profiles
ColumnType
iduuid primary key references auth.users
full_nametext
emailtext
roletext default 'user'

RLS: Per-user write own. Admin reads all.

Components
AdCard

Thumbnail + title + price + location for browse grids

AdGallery

shadcn Carousel for up to 6 ad images

PostForm

Multi-step posting form with compressed image upload

InquiryForm

Inquiry form with hidden honeypot field

LocationFilter

Text input filter for location field

CategoryNav

Scrollable horizontal category pills

AdminGuard

Protects /admin/* via has_role('admin')

Follow-up prompts

Paste these into Agent Mode one by one, in order, after the starter prompt finishes.

1

Anti-spam hardening: honeypot + IP rate limit + Cloudflare Turnstile

Honeypot + IP rate limit + Cloudflare Turnstile + email confirmation gate — four spam layers

~35-45 credits
prompt
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

2

Anonymous-insert RLS audit and lockdown

Verifies inquiries and reports tables have INSERT-only anon RLS with no SELECT leakage

~15-20 credits
prompt
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

3

Scheduled expiry via pg_cron

pg_cron hourly job that expires stale ads and optional 3-day warning email

~25-30 credits
prompt
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

4

OpenAI content moderation on ad submit

OpenAI moderation API check on submit to block obviously prohibited content

~25-30 credits
prompt
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

5

Featured/bumped ads with Stripe Checkout

Stripe-powered $5 ad bump to top of category for 7 days — primary monetization path

~45-55 credits
prompt
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.

Fix — paste into Lovable Agent Mode
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/inquiries

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

Fix — paste into Lovable Agent Mode
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 runs

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

Fix — paste into Lovable Agent Mode
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 Large

Lovable users uploaded a 10-12MB phone photo directly; default Supabase Storage upload limit is 5MB.

Fix — paste into Lovable Agent Mode
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/null

The 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/...'.

Fix — paste into Lovable Agent Mode
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 Unauthorized

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

Fix — paste into Lovable Agent Mode
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
ItemCost
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.

Email

  • 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 consultation

30-min call. No commitment.

Want this built for you?

We ship production apps at a fixed price — $13K–$25K, 6–10 weeks, source code yours. You've seen what it takes; we do it every week.

Get a fixed-price quote

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.