Launch NowDocs

Newsletter

Collect newsletter subscribers with double opt-in and send broadcasts from the admin area, backed by a Resend audience.

Launch Now includes an optional newsletter built on Resend audiences and broadcasts. Visitors subscribe from the marketing footer and confirm by email, signed-in users toggle it from their account, and platform admins write, preview and schedule campaigns from /admin/newsletter.

The newsletter is off until you set RESEND_AUDIENCE_ID. While it's off, the footer form is hidden, the account toggle is disabled and the admin page shows a "Newsletter is off" notice.

How it works

Launch Now doesn't store subscribers in its own database. The Resend audience is the source of truth: each subscriber is a Resend contact, and unsubscribing sets the contact's unsubscribed flag.

FileRole
features/newsletter/newsletter.server.tsServer-only helpers: enabled check, token signing and verification, Resend contact lookup and upsert
lib/newsletter-actions.tsServer actions: subscribe, account status and toggle, send, history
app/api/newsletter/confirm/route.tsDouble opt-in confirmation endpoint
features/newsletter/newsletter-form.tsxFooter sign-up form
features/newsletter/newsletter-preference-card.tsxAccount toggle at /account/notifications
features/newsletter/newsletter-composer.tsxAdmin composer with preview and scheduling
features/newsletter/newsletter-history.tsxAdmin list of past broadcasts
features/newsletter/newsletter-format.tsMarkdown-like to HTML converter and email template

Setup

Create an audience in Resend

In the Resend dashboard, create an audience for your newsletter and copy its ID. You also need a verified sending domain, since broadcasts are sent from EMAIL_FROM.

Set the environment variable

Add the audience ID to your environment. RESEND_API_KEY and EMAIL_FROM are already required by the app.

.env
RESEND_AUDIENCE_ID=your-resend-audience-id

The variable is optional in lib/env.ts:

lib/env.ts
/** Resend audience for the newsletter; the feature is off when unset. */
RESEND_AUDIENCE_ID: z.string().optional(),

Restart the app

The footer form appears on the marketing pages and the admin page unlocks.

Good to know: RESEND_AUDIENCE_ID isn't listed in .env.example. Add it yourself when you enable the newsletter.

Subscribing with double opt-in

The footer form (NewsletterForm) is only rendered when newsletterEnabled() returns true:

features/marketing/footer.tsx
{newsletterEnabled() && <NewsletterForm />}

When a visitor submits an email, subscribeToNewsletter runs. It's a public action (no session needed):

  1. The email is trimmed and lowercased.
  2. If the address is already an active contact, nothing is sent.
  3. Otherwise, a confirmation email is sent with a link to /api/newsletter/confirm?token=…. The same address gets at most one confirmation email per minute (per server instance).
  4. The action always returns the same answer, so the form doesn't reveal whether an address is already subscribed. The form then shows "Check your inbox to confirm your subscription."

The token is signed by signNewsletterToken. It's a base64url payload holding the email and an expiry, plus an HMAC-SHA256 signature keyed with BETTER_AUTH_SECRET. It expires after 48 hours.

features/newsletter/newsletter.server.ts
/** Double opt-in token: base64url({ e: email, x: expiry }) + "." + HMAC. */
export function signNewsletterToken(email: string) {
  const payload = Buffer.from(
    JSON.stringify({
      e: email.trim().toLowerCase(),
      x: Date.now() + TOKEN_TTL_MS,
    })
  ).toString("base64url")
  return `${payload}.${sign(payload)}`
}

The confirmation endpoint

GET /api/newsletter/confirm verifies the token with a timing-safe comparison, checks the expiry, and creates or updates the Resend contact with unsubscribed: false. It then redirects to the homepage:

  • /?newsletter=confirmed on success.
  • /?newsletter=invalid when the token is missing, tampered with or expired, when the newsletter is off, or when Resend fails.

The footer form reads the newsletter query parameter on load and shows a success or error toast.

Rotating BETTER_AUTH_SECRET invalidates every confirmation link that hasn't been clicked yet, because tokens are signed with it.

Account preference

Signed-in users manage their subscription from the Newsletter card at /account/notifications. The card loads the current state with getMyNewsletterStatus and saves changes with setMyNewsletterSubscription.

Signed-in users have already proved they own their email, so there's no double opt-in here: toggling the switch creates or updates the contact for user.email right away. If the newsletter is off, the switch is disabled and the card says the newsletter isn't available.

Sending a newsletter

Platform admins open Admin → Newsletter (/admin/newsletter). See Admin area for who can access it.

The composer has:

  • Subject: up to 200 characters.
  • Content: a textarea with Write and Preview tabs. Content can be up to 50,000 characters.
  • Send at (optional): a local date and time. Leave it empty to send immediately.

After a confirmation dialog, the composer calls sendNewsletter:

lib/newsletter-actions.ts
const created = await resend.broadcasts.create({
  segmentId: audienceId,
  from: env.EMAIL_FROM,
  subject,
  html,
  name: subject,
})
// ...
const sent = await resend.broadcasts.send(
  created.data.id,
  sendAt ? { scheduledAt: new Date(sendAt).toISOString() } : undefined
)

The action refuses a scheduled date in the past. Resend skips unsubscribed contacts automatically.

Content format

The composer uses a small, dependency-free converter (newsletterContentToHtml) so the preview matches exactly what is sent. It supports:

SyntaxResult
#, ##, ### at the start of a blockHeadings
**bold**Bold
*italic*Italic
[text](https://example.com)Link (only http and https URLs)
Lines starting with - or * Bullet list
A blank lineNew paragraph

Everything else is HTML-escaped. renderNewsletterHtml wraps the content in the email template and always appends a footer with Resend's unsubscribe placeholder, {{{RESEND_UNSUBSCRIBE_URL}}}, which Resend replaces with each contact's unsubscribe link.

History

The History section lists broadcasts from listNewsletterBroadcasts. It calls resend.broadcasts.list() and keeps only broadcasts for your audience. Each row shows the subject, a status badge (such as sent, queued, scheduled or draft), the scheduled date and the sent date.

Server actions

ActionClientInputPurpose
subscribeToNewsletteraction (public)emailSends the double opt-in email
getMyNewsletterStatusauthActionnoneReturns enabled and subscribed for the current user
setMyNewsletterSubscriptionauthActionsubscribedSubscribes or unsubscribes the current user
sendNewsletteradminsubject, content, sendAt (optional ISO date with offset)Creates and sends or schedules a Resend broadcast
listNewsletterBroadcastsadminnoneReturns enabled and the audience's broadcasts

See Server actions for every action in the project.

Customizing

  • Confirmation email: edit sendConfirmationEmail in lib/newsletter-actions.ts. The subject uses siteConfig.name.
  • Email template: edit renderNewsletterHtml in features/newsletter/newsletter-format.ts. Keep the unsubscribe link.
  • Sender: broadcasts use EMAIL_FROM. Change the from field in sendNewsletter to use another address.
  • Form placement: NewsletterForm is a client component. Render it anywhere, guarded by newsletterEnabled() from a server component.

Next steps