Skip to main content
RapidDev - Software Development Agency

How to Build a Newsletter Subscriptions with Lovable

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'll build

  • subscribers and lists tables with a junction for list membership
  • A public signup Form widget with email input and list selection
  • Supabase Edge Function that sends a confirmation email via Resend on new signup
  • Token-based double opt-in confirmation flow with a dedicated confirm page
  • Admin DataTable showing all subscribers with status filters (Pending / Active / Unsubscribed)
  • List management page for creating and organizing subscriber lists
  • Unsubscribe page that deactivates the subscriber via a signed token in the URL
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner12 min read1–1.5 hoursLovable Free or higherApril 2026RapidDev Engineering Team
TL;DR

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

LovableFrontend and admin UI
SupabaseDatabase and Auth
Supabase Edge FunctionsEmail sending logic (Deno)
ResendTransactional email delivery
shadcn/uiForm, DataTable, Badge, Dialog components

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

1

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.

prompt.txt
1Create a newsletter subscription system with Supabase. Set up these tables:
2
3- lists: id (uuid pk), name (text not null), slug (text unique), description (text), is_public (bool default true), subscriber_count (int default 0), created_at
4- 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_at
5- 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)
6
7RLS:
8- subscribers: INSERT allowed for anon (new signups), SELECT/UPDATE allowed for authenticated only
9- lists: SELECT allowed for anon (for the signup form), INSERT/UPDATE/DELETE for authenticated only
10- list_members: INSERT allowed for anon, SELECT/UPDATE/DELETE for authenticated only
11
12Create 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.

2

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.

prompt.txt
1Build a SubscribeForm component at src/components/SubscribeForm.tsx.
2
3The form should work as an embeddable widget that can be used on any page.
4
5Form 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.
9
10On submit:
111. Insert into subscribers table: { email, first_name, status: 'pending', ip_address: null }
12 - Use onConflict: 'email' with upsert to handle re-subscribes gracefully
13 - If the existing subscriber status is 'active', show: 'You are already subscribed!'
142. Insert into list_members for each selected list
153. 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}.'
17
18Add 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.

3

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.

supabase/functions/send-confirmation/index.ts
1// supabase/functions/send-confirmation/index.ts
2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
4
5const cors = { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' }
6
7serve(async (req: Request) => {
8 if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })
9
10 const { email, subscriberId } = await req.json()
11
12 const supabase = createClient(
13 Deno.env.get('SUPABASE_URL') ?? '',
14 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
15 )
16
17 const { data: subscriber } = await supabase
18 .from('subscribers')
19 .select('confirmation_token')
20 .eq('id', subscriberId)
21 .single()
22
23 if (!subscriber) {
24 return new Response(JSON.stringify({ error: 'Subscriber not found' }), { status: 404, headers: cors })
25 }
26
27 const appUrl = Deno.env.get('APP_URL') ?? 'https://yourapp.com'
28 const confirmUrl = `${appUrl}/confirm?token=${subscriber.confirmation_token}`
29
30 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 })
43
44 if (!res.ok) {
45 const err = await res.text()
46 return new Response(JSON.stringify({ error: err }), { status: 500, headers: cors })
47 }
48
49 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.

4

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.

prompt.txt
1Build two pages:
2
31. src/pages/Confirm.tsx the confirmation landing page:
4- Read the 'token' query parameter from the URL using useSearchParams
5- 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 page
9
102. src/pages/Unsubscribe.tsx the unsubscribe landing page:
11- Read the 'token' query parameter from the URL
12- 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' Button
14- 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.'
16
17Add 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

supabase/functions/send-confirmation/index.ts
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
2import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
3
4const cors = {
5 'Access-Control-Allow-Origin': '*',
6 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
7 'Content-Type': 'application/json',
8}
9
10serve(async (req: Request) => {
11 if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })
12
13 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 }
17
18 const supabase = createClient(
19 Deno.env.get('SUPABASE_URL') ?? '',
20 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
21 )
22
23 const { data: subscriber, error } = await supabase
24 .from('subscribers')
25 .select('confirmation_token')
26 .eq('id', subscriberId)
27 .single()
28
29 if (error || !subscriber) {
30 return new Response(JSON.stringify({ error: 'Subscriber not found' }), { status: 404, headers: cors })
31 }
32
33 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}`
36
37 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>`
44
45 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 })
58
59 if (!res.ok) {
60 const errText = await res.text()
61 return new Response(JSON.stringify({ error: errText }), { status: 500, headers: cors })
62 }
63
64 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.

ChatGPT Prompt

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.

Lovable Prompt

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.

Build Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

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.