DEV FIELDNOTES
Email systems field guide 011Updated September 28, 2026

Double Opt-In with Next.js and Resend: A Production-Ready Guide

Build secure double opt-in email subscriptions in Next.js with Resend using single-use tokens, scanner-safe confirmation, consent records, and Topics.

A secure double opt-in workflow moving from signup through an email, a time-limited token, a verification shield, and confirmed preferences

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.

What double opt-in proves

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.

Subscription states
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 cycle

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

Scanner-safe confirmation
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 sync
A one-click GET is convenient but fragile

HTTP GET should be safe and repeatable. Requiring an explicit POST prevents link scanners and accidental previews from silently changing consent.

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.

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

lib/email-address.ts
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.

lib/newsletter-tokens.ts
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.

app/api/newsletter/route.ts
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()
}
Do not log the request body or raw token

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.

workers/send-newsletter-confirmation.ts
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.

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.

app/api/newsletter/confirm/route.ts — GET
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.

app/api/newsletter/confirm/route.ts — POST
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.

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.

workers/sync-confirmed-contact.ts
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)
}
Do not erase a global unsubscribe casually

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.

Resend behavior
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 event

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

Preference precedence
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 Resend

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

Double opt-in test matrix
[ ] 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 header

Also 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

Before enabling the form
[ ] 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 owner

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

The rule worth keeping

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.

Resend: How to properly get email consentResend: Official double opt-in exampleResend: The new Contacts experienceResend: Unsubscribe TopicsResend API: Create a ContactResend API: Update Contact TopicsResend: Engineering idempotency keysOWASP: Email Address Validation and VerificationOWASP: Forgot Password Cheat SheetNext.js: Route Handlers