Build a double opt-in newsletter system in Lovable with a subscriber form, email confirmation via Resend sent from a Supabase Edge Function, subscribers and lists tables, and an admin DataTable. Subscribers are only activated after clicking the confirmation link — protecting you from fake signups.
What you're building
Double opt-in is the gold standard for email list quality. When someone submits their email, they get a confirmation link before they are added to your active list. This prevents spam signups, ensures deliverability, and is legally required in many jurisdictions (GDPR, CASL).
The flow works like this: visitor submits the form → a row is inserted into subscribers with status 'pending' and a random confirmation_token → a Supabase Edge Function fires (triggered by a database webhook) → the Edge Function calls the Resend API to send a confirmation email → the visitor clicks the link → the confirm page calls an Edge Function that validates the token and sets status to 'active'.
The admin panel built in Lovable shows all subscribers across all lists. Filters let you switch between Pending, Active, and Unsubscribed views. The lists page lets you create named lists (like 'Weekly Digest' or 'Product Updates') and see subscriber counts per list.
Final result
A production-ready double opt-in newsletter signup system with email confirmation, admin management, and unsubscribe support.
Tech stack
Prerequisites
- Lovable Free account or higher
- Supabase project with URL and anon key saved to Cloud tab → Secrets
- Resend account with API key and a verified sending domain
- RESEND_API_KEY saved to Cloud tab → Secrets (no VITE_ prefix)
- Your confirmed 'from' email address (e.g. hello@yourdomain.com)
Build steps
Create the subscribers schema
Prompt Lovable to build the database tables for subscribers and lists. The confirmation token is generated in the database using Supabase's gen_random_uuid() function, keeping token generation server-side.
1Create a newsletter subscription system with Supabase. Set up these tables:23- lists: id (uuid pk), name (text not null), slug (text unique), description (text), is_public (bool default true), subscriber_count (int default 0), created_at4- subscribers: id (uuid pk), email (text unique not null), first_name (text), status (text check in ('pending', 'active', 'unsubscribed'), default 'pending'), confirmation_token (text unique, default gen_random_uuid()), confirmed_at (timestamptz), unsubscribed_at (timestamptz), ip_address (text), created_at5- list_members: id (uuid pk), subscriber_id (uuid references subscribers on delete cascade), list_id (uuid references lists on delete cascade), joined_at, UNIQUE(subscriber_id, list_id)67RLS:8- subscribers: INSERT allowed for anon (new signups), SELECT/UPDATE allowed for authenticated only9- lists: SELECT allowed for anon (for the signup form), INSERT/UPDATE/DELETE for authenticated only10- list_members: INSERT allowed for anon, SELECT/UPDATE/DELETE for authenticated only1112Create a trigger that increments lists.subscriber_count when a confirmed subscriber joins a list and decrements it on delete.13Create an index on subscribers(confirmation_token) and subscribers(email).Pro tip: Ask Lovable to also create a unique partial index: CREATE UNIQUE INDEX unique_active_email ON subscribers(email) WHERE status != 'unsubscribed'. This prevents duplicate active subscribers but allows someone who unsubscribed to re-subscribe.
Expected result: All three tables are created with triggers and indexes. TypeScript types are generated. The app shell appears in the preview.
Build the public signup form
Create the subscriber-facing Form widget that visitors see on your landing page. It submits directly to Supabase and then calls the confirmation Edge Function.
1Build a SubscribeForm component at src/components/SubscribeForm.tsx.23The form should work as an embeddable widget that can be used on any page.45Form fields (react-hook-form + zod):6- Email (Input type email, required, validated format)7- First Name (Input, optional)8- List selector: if there are multiple public lists, show Checkboxes. If only one public list exists, auto-select it silently.910On submit:111. Insert into subscribers table: { email, first_name, status: 'pending', ip_address: null }12 - Use onConflict: 'email' with upsert to handle re-subscribes gracefully13 - If the existing subscriber status is 'active', show: 'You are already subscribed!'142. Insert into list_members for each selected list153. Call the send-confirmation Edge Function via supabase.functions.invoke('send-confirmation', { body: { email, subscriberId } })164. Show a success state: 'Check your inbox! We sent a confirmation link to {email}.'1718Add a loading spinner to the submit Button during the async operations.19Add a privacy note below the form: 'We never share your email. Unsubscribe anytime.'Expected result: The signup form renders on the page. Submitting with a valid email creates a pending subscriber row and triggers the confirmation email.
Build the confirmation Edge Function
Create the Edge Function that sends the double opt-in confirmation email via Resend, and a second function that activates the subscriber when they click the link.
1// supabase/functions/send-confirmation/index.ts2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'45const cors = { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' }67serve(async (req: Request) => {8 if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })910 const { email, subscriberId } = await req.json()1112 const supabase = createClient(13 Deno.env.get('SUPABASE_URL') ?? '',14 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''15 )1617 const { data: subscriber } = await supabase18 .from('subscribers')19 .select('confirmation_token')20 .eq('id', subscriberId)21 .single()2223 if (!subscriber) {24 return new Response(JSON.stringify({ error: 'Subscriber not found' }), { status: 404, headers: cors })25 }2627 const appUrl = Deno.env.get('APP_URL') ?? 'https://yourapp.com'28 const confirmUrl = `${appUrl}/confirm?token=${subscriber.confirmation_token}`2930 const res = await fetch('https://api.resend.com/emails', {31 method: 'POST',32 headers: {33 'Authorization': `Bearer ${Deno.env.get('RESEND_API_KEY')}`,34 'Content-Type': 'application/json',35 },36 body: JSON.stringify({37 from: Deno.env.get('FROM_EMAIL') ?? 'hello@yourdomain.com',38 to: email,39 subject: 'Confirm your subscription',40 html: `<p>Click the link below to confirm your subscription:</p><p><a href="${confirmUrl}">Confirm my subscription</a></p><p>If you did not sign up, ignore this email.</p>`,41 }),42 })4344 if (!res.ok) {45 const err = await res.text()46 return new Response(JSON.stringify({ error: err }), { status: 500, headers: cors })47 }4849 return new Response(JSON.stringify({ success: true }), { headers: cors })50})Pro tip: Add APP_URL and FROM_EMAIL to Cloud tab → Secrets. APP_URL should be your published Lovable domain (e.g. https://yourapp.lovable.app). This makes the confirmation link work correctly in both staging and production.
Expected result: The Edge Function deploys. Calling it with a valid subscriberId sends an email via Resend with the confirmation link.
Build the confirmation and unsubscribe pages
Create the pages that handle the links in emails. The confirm page activates the subscriber. The unsubscribe page deactivates them — both by validating the token from the URL.
1Build two pages:231. src/pages/Confirm.tsx — the confirmation landing page:4- Read the 'token' query parameter from the URL using useSearchParams5- On mount, call supabase.from('subscribers').update({ status: 'active', confirmed_at: new Date().toISOString() }).eq('confirmation_token', token).eq('status', 'pending')6- If updated rows > 0: show a success Card with a checkmark icon and 'You are confirmed! Welcome aboard.'7- If no rows updated (token invalid or already used): show an error Card: 'This confirmation link is invalid or has already been used.'8- Add a Button that navigates to the home page9102. src/pages/Unsubscribe.tsx — the unsubscribe landing page:11- Read the 'token' query parameter from the URL12- Show a confirmation Card: 'Are you sure you want to unsubscribe? You will stop receiving all emails from us.'13- Add an 'Unsubscribe' Button (destructive) and a 'Keep my subscription' Button14- On confirm: call supabase.from('subscribers').update({ status: 'unsubscribed', unsubscribed_at: new Date().toISOString() }).eq('confirmation_token', token)15- Show a final message: 'You have been unsubscribed. We will miss you.'1617Add both routes to the app router.Expected result: Clicking the confirmation link in the email activates the subscriber. The unsubscribe page shows a confirmation step before deactivating.
Complete code
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'2import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'34const cors = {5 'Access-Control-Allow-Origin': '*',6 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',7 'Content-Type': 'application/json',8}910serve(async (req: Request) => {11 if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })1213 const { email, subscriberId } = await req.json()14 if (!email || !subscriberId) {15 return new Response(JSON.stringify({ error: 'email and subscriberId required' }), { status: 400, headers: cors })16 }1718 const supabase = createClient(19 Deno.env.get('SUPABASE_URL') ?? '',20 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''21 )2223 const { data: subscriber, error } = await supabase24 .from('subscribers')25 .select('confirmation_token')26 .eq('id', subscriberId)27 .single()2829 if (error || !subscriber) {30 return new Response(JSON.stringify({ error: 'Subscriber not found' }), { status: 404, headers: cors })31 }3233 const appUrl = Deno.env.get('APP_URL') ?? 'https://yourapp.lovable.app'34 const confirmUrl = `${appUrl}/confirm?token=${subscriber.confirmation_token}`35 const unsubUrl = `${appUrl}/unsubscribe?token=${subscriber.confirmation_token}`3637 const htmlBody = `38 <div style="font-family:sans-serif;max-width:600px;margin:0 auto">39 <h2>Confirm your subscription</h2>40 <p>Thanks for signing up! Click the button below to confirm your email address.</p>41 <a href="${confirmUrl}" style="background:#000;color:#fff;padding:12px 24px;text-decoration:none;border-radius:6px;display:inline-block">Confirm subscription</a>42 <p style="margin-top:32px;font-size:12px;color:#888">If you did not sign up, you can safely ignore this email. <a href="${unsubUrl}">Unsubscribe</a></p>43 </div>`4445 const res = await fetch('https://api.resend.com/emails', {46 method: 'POST',47 headers: {48 'Authorization': `Bearer ${Deno.env.get('RESEND_API_KEY')}`,49 'Content-Type': 'application/json',50 },51 body: JSON.stringify({52 from: Deno.env.get('FROM_EMAIL') ?? 'hello@yourdomain.com',53 to: email,54 subject: 'Please confirm your subscription',55 html: htmlBody,56 }),57 })5859 if (!res.ok) {60 const errText = await res.text()61 return new Response(JSON.stringify({ error: errText }), { status: 500, headers: cors })62 }6364 return new Response(JSON.stringify({ success: true }), { headers: cors })65})Customization ideas
Welcome email after confirmation
Add a second Edge Function (or extend the first) that fires when a subscriber's status is updated to 'active'. It sends a welcome email via Resend with links to your best content, a personal note from the founder, or a lead magnet download.
Subscriber segments and tags
Add a tags text array column to subscribers. Let admins assign tags in the DataTable (e.g. 'customer', 'trial', 'vip'). Add tag-based filtering in the admin view and use tags as targeting criteria for future broadcast sends.
Broadcast send dashboard
Add a broadcasts table (subject, body_html, list_id, scheduled_at, sent_count). Build an admin page to compose and schedule broadcasts. A scheduled Edge Function checks for broadcasts past their scheduled_at and sends them via Resend batch API in chunks of 100.
Embedded widget for any page
The SubscribeForm component is already designed to be embeddable. Wrap it in a standalone route at /widget/subscribe and render it inside an iframe on external sites. Pass a list_id query param to pre-select which list new subscribers join.
Subscriber analytics chart
Add a Dashboard page with a Recharts area chart showing new subscriber signups per day over the past 30 days. A second chart shows list growth over time. Aggregate from the subscribers table using created_at grouped by date.
Common pitfalls
Pitfall: Using VITE_RESEND_API_KEY instead of RESEND_API_KEY in Secrets
How to avoid: Store the Resend API key in Cloud tab → Secrets as RESEND_API_KEY (no prefix). Access it in the Edge Function with Deno.env.get('RESEND_API_KEY'). Never reference it in any frontend component.
Pitfall: Letting anon users update subscriber status directly from the frontend
How to avoid: Move the confirmation update inside an Edge Function that validates the token server-side. The frontend calls the Edge Function with the token; the Edge Function does the update using the service role key.
Pitfall: Not handling duplicate email submissions
How to avoid: Use upsert with onConflict: 'email' on the INSERT. Check the returned subscriber's existing status: if 'active', show 'Already subscribed'; if 'pending', show 'Check your inbox'; if 'unsubscribed', send a new confirmation.
Pitfall: Sending confirmation emails synchronously in the form submit handler
How to avoid: Call supabase.functions.invoke() without awaiting the full response for the email send. Show the success state immediately after the database insert succeeds, before the email is confirmed sent.
Best practices
- Always use double opt-in. Single opt-in lists accumulate typos and spam traps which destroy your sender reputation over time.
- Store the confirmation_token as a UUID generated in the database (gen_random_uuid()), not in the frontend. Frontend-generated tokens can be predictable or intercepted.
- Add an expiry to confirmation tokens by adding a token_expires_at column (24-48 hours after creation). Check the expiry before activating the subscriber and prompt re-subscription if expired.
- Never expose the service role key to the frontend. The signup form uses the anon key for the initial INSERT; the Edge Function uses the service role key for the confirmation UPDATE.
- Log all email send attempts in a email_log table (subscriber_id, email_type, sent_at, resend_id). This lets you debug delivery issues and avoid double-sending.
- Include a physical mailing address in all emails — required by CAN-SPAM in the US. Add it to the email HTML template in the Edge Function.
AI prompts to try
Copy these prompts to build this project faster.
I have a Supabase newsletter system with subscribers (email, status, confirmation_token) and list_members tables. I want to add a broadcast sending feature. Design a broadcasts table schema and an Edge Function that accepts a broadcast_id, fetches all active subscribers in the target list, and sends the email via Resend batch API in chunks of 100 to stay within rate limits. Include error handling for failed sends and a way to track sent_count.
Add an admin subscriber import page at /subscribers/import. It has a Textarea where admins paste a CSV with columns: email, first_name. Parse the CSV in the browser, validate each row (valid email format), then show a preview DataTable with a row count and any validation errors highlighted. Add an 'Import X subscribers' Button that upserts valid rows into the subscribers table with status 'active' (bypassing double opt-in for manual imports). Show a progress indicator during import.
In Supabase, create a database webhook that fires after INSERT on the subscribers table. It should call my send-confirmation Edge Function URL with the new row's id and email as the body. Configure the webhook to fire only when status = 'pending'. Show me how to set this up in the Supabase Dashboard under Database → Webhooks so the Edge Function is called automatically on every new signup without needing to call it from the frontend.
Frequently asked questions
Is double opt-in legally required?
It depends on your jurisdiction. GDPR (EU) does not explicitly mandate double opt-in but requires clear consent, making it the safest approach. CASL (Canada) requires express consent that double opt-in provides. CAN-SPAM (US) does not require it but double opt-in protects you from spam complaints. For any audience with EU or Canadian members, use double opt-in.
Do I need a paid Resend plan?
Resend's free plan allows 3,000 emails per month and 100 emails per day. For a growing newsletter, the $20/month Starter plan allows 50,000 emails per month. The Edge Function uses the same API regardless of plan. If your confirmation email volume exceeds the free tier daily limit, some confirmations will fail — upgrade before launch.
What if someone never clicks the confirmation link?
They stay in 'pending' status indefinitely. Add a scheduled Edge Function (run daily via Supabase cron) that deletes pending subscribers where created_at < now() - interval '7 days'. This keeps your list clean and prevents your subscribers table from growing with ghost entries.
Can the same email be in multiple lists?
Yes. The list_members junction table allows one subscriber to be a member of multiple lists. The signup form can show checkboxes for all public lists. When someone unsubscribes, they are marked unsubscribed at the subscribers level, which removes them from all lists simultaneously.
How do I add the signup form to an external website?
Publish your Lovable app and add the SubscribeForm at a dedicated route like /subscribe. Embed it in an iframe on any external site. Alternatively, expose a POST endpoint via an Edge Function that accepts email and list_id as JSON, which any form (Webflow, Framer, HTML) can call directly.
Is there a way to see which emails were opened or clicked?
Open and click tracking is handled by Resend, not your database. In the Resend dashboard, you can see delivery, open, and click rates per email. To correlate these with your subscriber records, store the Resend email ID (returned in the API response) in your email_log table and match it to Resend's webhook events.
How do I export my subscriber list?
In the admin DataTable, add an Export button that calls supabase.from('subscribers').select('email, first_name, status, confirmed_at').eq('status', 'active') and converts the result to a CSV using a library like papaparse. The file downloads directly in the browser. Include the first_name column for personalization in email tools like Mailchimp if you migrate later.
Can I get help building a more advanced email marketing system?
RapidDev builds full email marketing systems on Lovable including broadcast sending, automated sequences, and analytics. Reach out if your newsletter needs go beyond the double opt-in pattern in this guide.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation