Landing Page with Email Capture Template for Lovable
One offer, one action, a real indexable copy body, and the insert-only policy that stops your email list being publicly downloadable.
Stack: React + Vite, TypeScript, Tailwind CSS, shadcn/ui, Supabase
What is in the download
- A build prompt that starts from a traffic source rather than a layout
- A leads table with case-insensitive deduplication and UTM capture
- An insert-only RLS policy for anonymous visitors, with no select policy at all
- A capture function so a repeat submission confirms instead of erroring, without a read
- Seven verification queries, one of which proves your list is not readable from the browser
What it is not
- Not a pre-built app you clone and run. There is no repository and no node_modules.
- Not a design file or a theme. Lovable generates the interface from the prompt.
- Not a substitute for reading the SQL. You are running it against your own database.
Setup order
Running these out of order is the usual reason a template appears broken. The schema comes before the prompt, because the prompt references the table names it creates.
Run schema.sql in the Supabase SQL editor
The prompt writes through capture_lead(), which this file creates.
Run verify.sql from the browser client as an anonymous visitor
Query 2 is the one that matters. If a bare select on leads returns rows, stop and fix it before shipping.
Paste prompt.md into Lovable
Then replace the offer and the audience.
Read your list from the dashboard or a server function
Never from the browser. There is no read policy, deliberately.
Things that catch people out
- The single most damaging mistake in a generated landing page is granting select on the leads table to anon so that a debug view works. That makes every address downloadable by anyone holding your project's public key, which is published in your own frontend.
- The prompt explicitly forbids inventing testimonials, logos or numbers. Lovable will otherwise generate plausible social proof, and fabricated proof on a live page is a real problem rather than a placeholder.
- A page that is only a hero and a form has almost nothing to rank with. The prompt asks for a copy body and FAQ structured data for that reason.
prompt.md
DownloadThe build prompt
# Context
Build a single landing page whose job is to capture an email address before a product
launches. The visitor arrives from one specific traffic source, so the page has one offer and
one action. Everything that does not serve the email capture is cut.
## Core Features (Priority Order)
1. Hero with the offer stated in one sentence and the form immediately visible, no scroll
2. Email capture form writing to the leads table in schema.sql, with UTM parameters captured
3. Inline success state that replaces the form, rather than a redirect or an alert
4. Duplicate handling: a repeat submission of the same address confirms rather than errors
5. Three proof blocks below the fold, using placeholder content that is obviously placeholder
6. Server-rendered copy body so the page can rank, not only a hero and a form
## Visual Style
- One accent colour, used for the submit button and nothing else
- shadcn/ui: Input and Button for the form, Card for the proof blocks, Alert for the
success state
- Generous vertical rhythm, maximum 65 characters per line in body copy
## Technical Requirements
- Breakpoints sm 640px, md 768px, lg 1024px, mobile first
- Real title, meta description and canonical tag, plus Open Graph tags
- The form must work with JavaScript disabled as a progressive enhancement target, or if
that is not possible, must not render an empty shell to a crawler
- Client and server side email validation
- No layout shift when the success state replaces the form
## Implementation Strategy
1. Static page with real copy first, so there is something to index
2. Then the form and the write path, including duplicate handling
3. Then UTM capture, which is invisible and easy to get wrong
4. Analytics last
## Safe-Guard Instructions
- The leads table must accept an insert from an anonymous visitor and must never allow a
select. Do not add a read policy to make debugging easier.
- Do not invent testimonials, logos, customer names or numbers. Use placeholders that
clearly read as placeholders.
- Do not add a second call to action. One page, one action.
## Growth Features (Required for Distribution)
- **Organic SEO:** the copy body is the ranking surface, so it must be real text in the HTML
rather than an image or a client-rendered block. Include an FAQ section marked up as
FAQPage structured data.
- **Email Marketing:** capture the UTM source on every row so the list can be segmented by
where each address came from.schema.sql
DownloadLeads table and capture function
-- Landing page lead capture for Lovable + Supabase
-- Reviewed row-level security. Read the comments before running this.
--
-- THE ONE THING THAT MATTERS HERE: a public form needs INSERT for anonymous visitors and
-- must never grant SELECT. If you grant select to anon, your entire email list is
-- downloadable by anyone with your project's public key. That is the single most common
-- and most damaging mistake in a generated landing page.
create table public.leads (
id uuid primary key default gen_random_uuid(),
email text not null,
-- Case-insensitive uniqueness, so Foo@x.com and foo@x.com are one lead.
email_normalised text generated always as (lower(trim(email))) stored,
source text,
utm_source text,
utm_medium text,
utm_campaign text,
referrer text,
created_at timestamptz not null default now(),
constraint leads_email_shape check (email ~* '^[^@\s]+@[^@\s]+\.[^@\s]+$')
);
create unique index leads_email_unique on public.leads (email_normalised);
create index leads_created_idx on public.leads (created_at desc);
create index leads_utm_source_idx on public.leads (utm_source);
alter table public.leads enable row level security;
-- Anonymous visitors may add themselves and nothing else.
create policy "leads: anon insert" on public.leads
for insert to anon, authenticated with check (true);
-- Deliberately NO select, update or delete policy for anon or authenticated.
-- Read your list from the Supabase dashboard, a server function using the service role, or
-- an admin surface that authenticates first. Never from the browser.
-- Duplicate submissions must confirm rather than error, but the client cannot use
-- "on conflict do nothing" and learn whether the row existed without a read. So the write
-- goes through a function that returns nothing either way.
create or replace function public.capture_lead(
_email text,
_source text default null,
_utm_source text default null,
_utm_medium text default null,
_utm_campaign text default null,
_referrer text default null
)
returns void
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.leads (email, source, utm_source, utm_medium, utm_campaign, referrer)
values (_email, _source, _utm_source, _utm_medium, _utm_campaign, _referrer)
on conflict (email_normalised) do nothing;
end;
$$;
grant execute on function public.capture_lead(text, text, text, text, text, text)
to anon, authenticated;verify.sql
DownloadPolicy verification queries
-- Verification queries. Run these from the browser client, as an anonymous visitor.
-- The second one is the important one.
-- 1. Should SUCCEED. An anonymous visitor can join the list.
select public.capture_lead('verify@example.com', 'verification');
-- 2. Should return ZERO ROWS, not an error and not data.
-- If this returns rows, your email list is publicly readable. Stop and fix the policy.
select * from public.leads;
-- 3. Should return zero rows. Counting is still reading.
select count(*) from public.leads;
-- 4. Should FAIL. No update policy exists.
update public.leads set email = 'changed@example.com' where email = 'verify@example.com';
-- 5. Should FAIL. No delete policy exists.
delete from public.leads where email = 'verify@example.com';
-- 6. Should SUCCEED silently and create no second row.
select public.capture_lead('VERIFY@example.com', 'verification');
-- 7. Confirm RLS is on.
select tablename, rowsecurity from pg_tables
where schemaname = 'public' and tablename = 'leads';Next
If you want a prompt rather than a whole starting point, the prompt library has 101 of them across six categories. If you are watching what this costs to build, how Lovable credits work explains why a vague prompt against a large project is expensive twice over.