# How to Build a Newsletter Subscriptions with Lovable

- Tool: How to Build with Lovable
- Difficulty: Beginner
- Compatibility: Lovable Free or higher
- Last updated: April 2026

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

## Before you start

- 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)

## Step-by-step guide

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

```
Create a newsletter subscription system with Supabase. Set up these tables:

- 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
- 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
- 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)

RLS:
- subscribers: INSERT allowed for anon (new signups), SELECT/UPDATE allowed for authenticated only
- lists: SELECT allowed for anon (for the signup form), INSERT/UPDATE/DELETE for authenticated only
- list_members: INSERT allowed for anon, SELECT/UPDATE/DELETE for authenticated only

Create a trigger that increments lists.subscriber_count when a confirmed subscriber joins a list and decrements it on delete.
Create 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.

```
Build a SubscribeForm component at src/components/SubscribeForm.tsx.

The form should work as an embeddable widget that can be used on any page.

Form fields (react-hook-form + zod):
- Email (Input type email, required, validated format)
- First Name (Input, optional)
- List selector: if there are multiple public lists, show Checkboxes. If only one public list exists, auto-select it silently.

On submit:
1. Insert into subscribers table: { email, first_name, status: 'pending', ip_address: null }
   - Use onConflict: 'email' with upsert to handle re-subscribes gracefully
   - If the existing subscriber status is 'active', show: 'You are already subscribed!'
2. Insert into list_members for each selected list
3. Call the send-confirmation Edge Function via supabase.functions.invoke('send-confirmation', { body: { email, subscriberId } })
4. Show a success state: 'Check your inbox! We sent a confirmation link to {email}.'

Add a loading spinner to the submit Button during the async operations.
Add 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
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const cors = { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' }

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })

  const { email, subscriberId } = await req.json()

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

  const { data: subscriber } = await supabase
    .from('subscribers')
    .select('confirmation_token')
    .eq('id', subscriberId)
    .single()

  if (!subscriber) {
    return new Response(JSON.stringify({ error: 'Subscriber not found' }), { status: 404, headers: cors })
  }

  const appUrl = Deno.env.get('APP_URL') ?? 'https://yourapp.com'
  const confirmUrl = `${appUrl}/confirm?token=${subscriber.confirmation_token}`

  const res = await fetch('https://api.resend.com/emails', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${Deno.env.get('RESEND_API_KEY')}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      from: Deno.env.get('FROM_EMAIL') ?? 'hello@yourdomain.com',
      to: email,
      subject: 'Confirm your subscription',
      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>`,
    }),
  })

  if (!res.ok) {
    const err = await res.text()
    return new Response(JSON.stringify({ error: err }), { status: 500, headers: cors })
  }

  return new Response(JSON.stringify({ success: true }), { headers: cors })
})
```

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

```
Build two pages:

1. src/pages/Confirm.tsx — the confirmation landing page:
- Read the 'token' query parameter from the URL using useSearchParams
- On mount, call supabase.from('subscribers').update({ status: 'active', confirmed_at: new Date().toISOString() }).eq('confirmation_token', token).eq('status', 'pending')
- If updated rows > 0: show a success Card with a checkmark icon and 'You are confirmed! Welcome aboard.'
- If no rows updated (token invalid or already used): show an error Card: 'This confirmation link is invalid or has already been used.'
- Add a Button that navigates to the home page

2. src/pages/Unsubscribe.tsx — the unsubscribe landing page:
- Read the 'token' query parameter from the URL
- Show a confirmation Card: 'Are you sure you want to unsubscribe? You will stop receiving all emails from us.'
- Add an 'Unsubscribe' Button (destructive) and a 'Keep my subscription' Button
- On confirm: call supabase.from('subscribers').update({ status: 'unsubscribed', unsubscribed_at: new Date().toISOString() }).eq('confirmation_token', token)
- Show a final message: 'You have been unsubscribed. We will miss you.'

Add 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 example

File: `supabase/functions/send-confirmation/index.ts`

```typescript
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const cors = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Content-Type': 'application/json',
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })

  const { email, subscriberId } = await req.json()
  if (!email || !subscriberId) {
    return new Response(JSON.stringify({ error: 'email and subscriberId required' }), { status: 400, headers: cors })
  }

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

  const { data: subscriber, error } = await supabase
    .from('subscribers')
    .select('confirmation_token')
    .eq('id', subscriberId)
    .single()

  if (error || !subscriber) {
    return new Response(JSON.stringify({ error: 'Subscriber not found' }), { status: 404, headers: cors })
  }

  const appUrl = Deno.env.get('APP_URL') ?? 'https://yourapp.lovable.app'
  const confirmUrl = `${appUrl}/confirm?token=${subscriber.confirmation_token}`
  const unsubUrl = `${appUrl}/unsubscribe?token=${subscriber.confirmation_token}`

  const htmlBody = `
    <div style="font-family:sans-serif;max-width:600px;margin:0 auto">
      <h2>Confirm your subscription</h2>
      <p>Thanks for signing up! Click the button below to confirm your email address.</p>
      <a href="${confirmUrl}" style="background:#000;color:#fff;padding:12px 24px;text-decoration:none;border-radius:6px;display:inline-block">Confirm subscription</a>
      <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>
    </div>`

  const res = await fetch('https://api.resend.com/emails', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${Deno.env.get('RESEND_API_KEY')}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      from: Deno.env.get('FROM_EMAIL') ?? 'hello@yourdomain.com',
      to: email,
      subject: 'Please confirm your subscription',
      html: htmlBody,
    }),
  })

  if (!res.ok) {
    const errText = await res.text()
    return new Response(JSON.stringify({ error: errText }), { status: 500, headers: cors })
  }

  return new Response(JSON.stringify({ success: true }), { headers: cors })
})
```

## Common mistakes

- **Using VITE_RESEND_API_KEY instead of RESEND_API_KEY in Secrets** — The VITE_ prefix exposes the value in the browser JavaScript bundle. Your Resend API key would be visible to anyone who opens browser DevTools. Fix: 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.
- **Letting anon users update subscriber status directly from the frontend** — If the confirmation logic runs in the frontend using the anon key, anyone who knows a valid token can activate any subscriber — including by brute-forcing tokens. Fix: 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.
- **Not handling duplicate email submissions** — Users often submit the form twice if the page does not show a clear success state. Without duplicate handling, you get a database unique constraint error shown to the user. Fix: 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.
- **Sending confirmation emails synchronously in the form submit handler** — If Resend is slow or down, the form appears frozen for 3-10 seconds. Users think it is broken and submit multiple times. Fix: 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.

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

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/newsletter-subscriptions
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/newsletter-subscriptions
