A double opt-in flow does more than add a confirmation email after a newsletter form. It proves that someone can access the submitted mailbox, creates a stronger record of consent, and prevents typoed or forged addresses from entering your marketing list. The visible experience is one click. The production system behind that click needs tokens, expiry, retry safety, abuse controls, and a clear boundary between your database and Resend.
This guide builds that system with Next.js App Router, a relational database, and Resend. The design stores only token digests, avoids leaking whether an address is already subscribed, survives duplicate requests, and protects against a subtle failure in many tutorials: security scanners that open confirmation links before the recipient does.
It proves control of a mailbox at the moment of confirmation. It does not prove a person’s legal identity, and it does not replace jurisdiction-specific compliance advice.
Start with the state machine
Treat subscription as a state transition, not a boolean written by the signup form. A new request creates a pending subscriber. Only a valid, unexpired confirmation action can move that subscriber to confirmed. A later unsubscribe must continue to win until the person explicitly opts in again.
submitted
-> pending subscriber
-> confirmation token issued
-> confirmation email accepted by Resend
-> recipient explicitly confirms
-> consent event recorded
-> Resend Contact, Segment, and Topic synchronized
confirmed
-> unsubscribe
-> marketing blocked
-> later explicit signup starts a new confirmation cycleKeep pending subscribers out of the marketing Topic. The confirmation message itself is transactional because it is the direct result of a signup request. Newsletters and promotions remain marketing messages and should begin only after confirmation.
Do not confirm on a GET request
The most important design decision is easy to miss: opening the URL from the email should not consume the token. Corporate email gateways, antivirus products, and link-preview services may prefetch URLs to inspect them. If GET changes the subscription state, a scanner can confirm the address before the person sees the message.
Use a two-step flow. The GET route validates the token, creates a short-lived browser session, and redirects to a clean confirmation page. The page asks the person to press Confirm. That POST request consumes the token and records consent. A scanner can visit the first URL without completing the state change.
Email link GET /api/newsletter/confirm?token=...
-> hash token and validate pending record
-> create short-lived confirmation session
-> set HttpOnly, Secure, SameSite=Lax cookie
-> 303 redirect to /newsletter/confirm
User presses Confirm
-> POST /api/newsletter/confirm
-> validate session, token, and expiry in one transaction
-> mark token and session used
-> change subscriber to confirmed
-> append consent event and queue Resend syncHTTP GET should be safe and repeatable. Requiring an explicit POST prevents link scanners and accidental previews from silently changing consent.
Model subscribers, tokens, consent, and delivery separately
A durable schema separates current subscription state from the credentials used to change it. Store the display address for email delivery and a comparison key for uniqueness. Tokens and browser sessions get their own tables so they can expire and be consumed atomically. Consent events stay append-only for auditability.
create table newsletter_subscribers (
id uuid primary key,
email_display text not null,
email_key text not null unique,
status text not null check (status in ('pending', 'confirmed', 'unsubscribed')),
confirmed_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table newsletter_confirmation_tokens (
id uuid primary key,
subscriber_id uuid not null references newsletter_subscribers(id),
token_hash text not null unique,
expires_at timestamptz not null,
used_at timestamptz,
created_at timestamptz not null default now()
);
create table newsletter_confirmation_sessions (
id uuid primary key,
token_id uuid not null references newsletter_confirmation_tokens(id),
session_hash text not null unique,
expires_at timestamptz not null,
used_at timestamptz,
created_at timestamptz not null default now()
);
create table marketing_consent_events (
id uuid primary key,
subscriber_id uuid not null references newsletter_subscribers(id),
action text not null check (action in ('opt_in', 'opt_out')),
topic_key text not null,
source text not null,
form_version text,
policy_version text,
occurred_at timestamptz not null default now()
);
create table integration_outbox (
id uuid primary key,
type text not null,
dedupe_key text not null unique,
payload jsonb not null,
processed_at timestamptz,
created_at timestamptz not null default now()
);Do not store the raw confirmation token. If the database is exposed, a raw token can be used immediately. Store a SHA-256 digest and send the original only in the email. This mirrors the design of password-reset credentials without treating the newsletter as an authentication system.
Normalize email addresses deliberately
Trim surrounding whitespace and lowercase the domain because domain names are case-insensitive. Lowercasing the local part is a product decision, not a universal email rule. Many providers treat it case-insensitively, but the standards permit case-sensitive local parts. Preserve the original address for delivery and make your comparison policy explicit.
export function emailComparisonKey(input: string) {
const email = input.trim()
const at = email.lastIndexOf('@')
if (at < 1) throw new Error('Invalid email address')
const local = email.slice(0, at)
const domain = email.slice(at + 1).toLowerCase()
return local + '@' + domain
}Generate a strong, single-use token
Node.js can generate the token without another dependency. Thirty-two random bytes provide ample entropy. Base64url encoding keeps the value compact and URL-safe. Hash it before database storage, bind it to one subscriber, and give it a short lifetime with a clear resend path.
import { createHash, randomBytes } from 'node:crypto'
export function issueSecret() {
return randomBytes(32).toString('base64url')
}
export function digestSecret(secret: string) {
return createHash('sha256').update(secret, 'utf8').digest('hex')
}
export function expiresIn(minutes: number) {
return new Date(Date.now() + minutes * 60_000)
}This guide uses a 30-minute confirmation window and a ten-minute browser session. Longer windows reduce friction; shorter windows reduce the period in which a forwarded or leaked link is useful. Whatever duration you choose, enforce it in the database transaction instead of relying only on the page UI.
Accept signups without revealing subscriber state
The signup route should return the same message for a new address, a pending address, and an already confirmed address. A response such as “If this address is eligible, we sent a confirmation link” prevents the endpoint from becoming a subscriber-discovery API.
Validate the body, rate-limit by IP and an email fingerprint, and write the subscriber, token, and outbox item in one database transaction. Invalidate older unused tokens before issuing a new one. The browser never calls Resend directly.
import { randomUUID } from 'node:crypto'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { db } from '@/lib/db'
import { emailComparisonKey } from '@/lib/email-address'
import { digestSecret, expiresIn, issueSecret } from '@/lib/newsletter-tokens'
const Signup = z.object({
email: z.string().trim().email().max(320),
consent: z.literal(true),
})
const accepted = () =>
NextResponse.json(
{ message: 'If eligible, we sent a confirmation link.' },
{ status: 202 },
)
export async function POST(request: Request) {
const parsed = Signup.safeParse(await request.json())
if (!parsed.success) return accepted()
const emailDisplay = parsed.data.email.trim()
const emailKey = emailComparisonKey(emailDisplay)
// Apply per-IP and per-email-fingerprint limits before the transaction.
const rawToken = issueSecret()
const tokenId = randomUUID()
await db.transaction(async (tx) => {
const subscriber = await tx.newsletterSubscribers.upsertPending({
emailDisplay,
emailKey,
})
if (subscriber.status === 'confirmed') return
await tx.confirmationTokens.invalidateUnused(subscriber.id)
await tx.confirmationTokens.insert({
id: tokenId,
subscriberId: subscriber.id,
tokenHash: digestSecret(rawToken),
expiresAt: expiresIn(30),
})
await tx.outbox.insert({
id: randomUUID(),
type: 'newsletter.confirmation.send',
dedupeKey: 'double-opt-in/' + tokenId,
payload: { tokenId, email: emailDisplay, rawToken },
})
})
return accepted()
}Structured logs should contain a request ID, token record ID, and a keyed email fingerprint—not the address or confirmation secret. Also sanitize error-reporting breadcrumbs and queue dashboards.
Send the confirmation email with a stable idempotency key
A worker should deliver the outbox item. Resend accepts an idempotency key as the second SDK argument and remembers it for 24 hours. Retrying the same token with the same key avoids duplicate messages after an ambiguous timeout. A deliberately reissued token gets a new token ID and therefore a new key.
import { resend } from '@/lib/resend'
const appOrigin = new URL(process.env.APP_ORIGIN!).origin
export async function sendConfirmation(job: {
tokenId: string
email: string
rawToken: string
}) {
const confirmationUrl = new URL('/api/newsletter/confirm', appOrigin)
confirmationUrl.searchParams.set('token', job.rawToken)
const { error } = await resend.emails.send(
{
from: 'Dev Fieldnotes <newsletter@example.com>',
to: job.email,
subject: 'Confirm your subscription',
html: [
'<h1>Confirm your subscription</h1>',
'<p>Press the button below to continue.</p>',
'<p><a href="' + confirmationUrl.toString() + '">Review and confirm</a></p>',
'<p>This link expires in 30 minutes.</p>',
].join(''),
},
{ idempotencyKey: 'double-opt-in/' + job.tokenId },
)
if (error) throw error
}The confirmation email should be narrowly transactional: explain why it arrived, name the list, show the expiry, and provide a way to ignore the request. Do not add promotions to a message sent before marketing consent exists.
Turn the link into a short-lived confirmation session
The GET route receives the only copy of the raw token, hashes it, and looks up an unused record. It then creates a second random secret for the browser session. Redirecting immediately removes the confirmation token from the address bar, browser history, analytics URLs, and referrer headers on the visible page.
import { randomUUID } from 'node:crypto'
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { digestSecret, expiresIn, issueSecret } from '@/lib/newsletter-tokens'
export async function GET(request: Request) {
const token = new URL(request.url).searchParams.get('token')
if (!token || token.length > 256) {
return NextResponse.redirect(new URL('/newsletter/invalid', request.url), 303)
}
const record = await db.confirmationTokens.findUsableByHash(
digestSecret(token),
new Date(),
)
if (!record) {
return NextResponse.redirect(new URL('/newsletter/invalid', request.url), 303)
}
const rawSession = issueSecret()
await db.confirmationSessions.insert({
id: randomUUID(),
tokenId: record.id,
sessionHash: digestSecret(rawSession),
expiresAt: expiresIn(10),
})
const cookieStore = await cookies()
cookieStore.set('newsletter_confirmation', rawSession, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/api/newsletter/confirm',
maxAge: 10 * 60,
})
const response = NextResponse.redirect(
new URL('/newsletter/confirm', request.url),
303,
)
response.headers.set('Referrer-Policy', 'no-referrer')
response.headers.set('Cache-Control', 'no-store')
return response
}Set Referrer-Policy: no-referrer and Cache-Control: no-store on the route and confirmation page. Redact token query parameters in reverse-proxy logs, analytics, error monitoring, and session replay. A hashed database value does not help if the raw value is copied into five observability products.
Consume the token atomically on POST
The POST route hashes the cookie secret and locks the session, token, and subscriber rows. It verifies that each record is unused and unexpired, then changes every relevant state in one transaction. This makes concurrent double-clicks and repeated requests harmless.
export async function POST(request: Request) {
const cookieStore = await cookies()
const rawSession = cookieStore.get('newsletter_confirmation')?.value
if (!rawSession) {
return NextResponse.redirect(new URL('/newsletter/invalid', request.url), 303)
}
const confirmed = await db.transaction(async (tx) => {
const session = await tx.confirmationSessions.lockUsableByHash(
digestSecret(rawSession),
new Date(),
)
if (!session) return false
const token = await tx.confirmationTokens.lockUsableById(
session.tokenId,
new Date(),
)
if (!token) return false
const subscriber = await tx.newsletterSubscribers.lockById(
token.subscriberId,
)
if (!subscriber) return false
const now = new Date()
await tx.confirmationSessions.markUsed(session.id, now)
await tx.confirmationTokens.markUsed(token.id, now)
await tx.newsletterSubscribers.markConfirmed(subscriber.id, now)
await tx.marketingConsentEvents.insert({
id: randomUUID(),
subscriberId: subscriber.id,
action: 'opt_in',
topicKey: 'weekly-newsletter',
source: 'double-opt-in',
formVersion: '2026-09',
policyVersion: '2026-09',
occurredAt: now,
})
await tx.outbox.insertIfAbsent({
id: randomUUID(),
type: 'resend.contact.sync',
dedupeKey: 'resend-contact-confirmed/' + token.id,
payload: { subscriberId: subscriber.id },
})
return true
})
cookieStore.delete('newsletter_confirmation')
const path = confirmed ? '/newsletter/confirmed' : '/newsletter/invalid'
return NextResponse.redirect(new URL(path, request.url), 303)
}If a second POST arrives after success, return the same friendly success page or a neutral completed state. Idempotency is a user-experience feature as well as a consistency guarantee.
Sync confirmed consent to Resend
After confirmation, project the subscriber into Resend as a global Contact, add the intended Segment, and set the newsletter Topic to opt_in. Resend now uses global Contacts rather than separate Audience-scoped copies, so one address can belong to several Segments while maintaining Topic preferences.
Use a Topic with defaultSubscription set to opt_out for a list that requires affirmative consent. A global Contact with unsubscribed set to true cannot receive Broadcasts even when an individual Topic says opt_in. Treat that global state as a separate and stronger signal.
import { resend } from '@/lib/resend'
const segmentId = process.env.RESEND_NEWSLETTER_SEGMENT_ID!
const topicId = process.env.RESEND_NEWSLETTER_TOPIC_ID!
export async function syncConfirmedContact(subscriberId: string) {
const subscriber = await loadLatestSubscriberState(subscriberId)
if (subscriber.status !== 'confirmed') return
const created = await resend.contacts.create({
email: subscriber.emailDisplay,
unsubscribed: false,
segments: [{ id: segmentId }],
topics: [{ id: topicId, subscription: 'opt_in' }],
})
if (!created.error) return
if (!isDuplicateContactError(created.error)) throw created.error
// Update an existing Contact only after re-reading the latest local consent.
await assertExplicitConsentIsCurrent(subscriberId)
const topicResult = await resend.contacts.topics.update({
email: subscriber.emailDisplay,
topics: [{ id: topicId, subscription: 'opt_in' }],
})
if (topicResult.error) throw topicResult.error
await ensureSegmentMembership(subscriber.emailDisplay, segmentId)
}Setting unsubscribed to false can reactivate all Broadcast eligibility. Do it only when the new double opt-in is an explicit reconsent that occurred after the unsubscribe, and preserve other Topic opt-outs.
Make resend-confirmation safe
A resend button should use the same generic response as signup. Rate-limit it, invalidate older unused tokens, issue a new token, and queue a new message with a new idempotency key. Do not extend the lifetime of the old secret or disclose whether the address is pending.
First request -> token A -> idempotency key double-opt-in/<token-A-id>
Worker retry -> token A -> same key; Resend deduplicates within its window
User resends -> invalidate token A
User resends -> token B -> new key; a new message is allowed
Confirmation -> consume token B once
Later retry -> completed or neutral response; no second consent eventApply both burst and rolling limits—for example, a short cooldown plus a daily cap. Add a honeypot or challenge when abuse rises. Email delivery is not a free side effect, and an attacker should not be able to use your form to flood another person’s inbox.
Preserve unsubscribe precedence
Once a confirmed subscriber opts out, invalidate all outstanding confirmation sessions and record an opt_out event. Provider webhooks or a hosted preference page may be the source of that decision. Reconciliation must never allow a stale contact-sync job to turn it back on.
1. Latest explicit global unsubscribe blocks every marketing send
2. Latest explicit Topic opt-out blocks that Topic
3. Segment membership changes targeting, never consent
4. Imports and profile updates never create consent
5. A later explicit double opt-in may reactivate only what it names
6. Every projection checks the latest local event before writing to ResendThis ordering is why an append-only consent ledger matters. A mutable boolean can tell you the current answer, but not whether an older retry is allowed to replace it.
Test the failures, not only the happy path
A browser test that submits an address and clicks the link proves very little about production safety. The valuable tests force concurrency, replay, expiry, retries, scanner behavior, and state conflicts.
[ ] Signup always returns the same public response
[ ] Invalid input does not reveal subscriber state
[ ] Link-scanner GET does not confirm the subscriber
[ ] GET redirects away from the token-bearing URL
[ ] Expired token cannot create a confirmation session
[ ] POST without the HttpOnly session fails safely
[ ] First POST records exactly one consent event
[ ] Second POST is harmless and creates no duplicate outbox item
[ ] Concurrent POSTs produce one state transition
[ ] Resend-confirmation invalidates the previous token
[ ] Worker retry reuses the same Resend idempotency key
[ ] Unsubscribe beats a delayed contact-sync job
[ ] Raw tokens and email addresses are absent from logs
[ ] APP_ORIGIN cannot be influenced by the request Host headerAlso test a real confirmation email through the filtering systems your audience uses. Verify the intermediate page, cookie behavior, mobile layout, expiry message, and resend route. If a company gateway clicks the link, the subscriber must remain pending until a person presses Confirm.
Observe the funnel without collecting secrets
Measure signup requests, throttled requests, confirmation-email acceptance, delivery failures, median time to confirm, expiry rate, confirmation conversion, contact-sync failures, Topic opt-outs, global unsubscribes, bounces, and complaints. These metrics show both product friction and abuse.
Use opaque subscriber or token-record IDs in telemetry. Hashing an email without a secret is often reversible through guessing, so use an HMAC when you need a stable operational fingerprint. Never use the raw confirmation token as a trace ID.
Production checklist
[ ] Consent copy is specific, informed, and not preselected
[ ] Subscriber state includes pending, confirmed, and unsubscribed
[ ] Display address and comparison key are stored separately
[ ] Confirmation secrets use at least 32 random bytes
[ ] Only SHA-256 token digests are stored
[ ] Unused older tokens are invalidated on resend
[ ] Confirmation GET never changes consent
[ ] Explicit POST consumes token and session atomically
[ ] Confirmation routes use no-store and no-referrer
[ ] Proxies, analytics, and error tools redact token query values
[ ] Public responses do not reveal subscriber existence
[ ] Signup and resend routes have abuse controls
[ ] APP_ORIGIN is trusted server configuration
[ ] Resend sends use stable per-token idempotency keys
[ ] Pending subscribers are not in the marketing Topic
[ ] Confirmed subscribers sync to the correct Segment and Topic
[ ] Global unsubscribe and Topic preferences remain distinct
[ ] Consent events retain source, form, policy, and timestamp
[ ] Unsubscribe wins over stale queued work
[ ] Concurrency, replay, expiry, and scanner behavior are tested
[ ] Regional legal requirements have an accountable ownerThe production principle
The confirmation link is not the system. The system is a verifiable state transition: a pending request, a high-entropy one-time credential, an explicit human action, an append-only consent record, and a replayable projection into Resend. Each boundary should be observable without exposing the secrets that protect it.
Next.js Route Handlers provide the HTTP boundary, your database provides atomicity and history, and Resend provides transactional delivery plus marketing Contacts and Topics. Keep those responsibilities separate and double opt-in becomes a dependable consent workflow instead of a fragile email trick.
A GET may prepare confirmation; only an explicit, atomic POST should record it. That single design choice protects the entire flow from scanners, retries, and accidental clicks.
