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.
| File | Role |
|---|---|
features/newsletter/newsletter.server.ts | Server-only helpers: enabled check, token signing and verification, Resend contact lookup and upsert |
lib/newsletter-actions.ts | Server actions: subscribe, account status and toggle, send, history |
app/api/newsletter/confirm/route.ts | Double opt-in confirmation endpoint |
features/newsletter/newsletter-form.tsx | Footer sign-up form |
features/newsletter/newsletter-preference-card.tsx | Account toggle at /account/notifications |
features/newsletter/newsletter-composer.tsx | Admin composer with preview and scheduling |
features/newsletter/newsletter-history.tsx | Admin list of past broadcasts |
features/newsletter/newsletter-format.ts | Markdown-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.
RESEND_AUDIENCE_ID=your-resend-audience-idThe variable is optional in 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:
{newsletterEnabled() && <NewsletterForm />}When a visitor submits an email, subscribeToNewsletter runs. It's a public action (no session needed):
- The email is trimmed and lowercased.
- If the address is already an active contact, nothing is sent.
- 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). - 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.
/** 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=confirmedon success./?newsletter=invalidwhen 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.
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:
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:
| Syntax | Result |
|---|---|
#, ##, ### at the start of a block | Headings |
**bold** | Bold |
*italic* | Italic |
[text](https://example.com) | Link (only http and https URLs) |
Lines starting with - or * | Bullet list |
| A blank line | New 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
| Action | Client | Input | Purpose |
|---|---|---|---|
subscribeToNewsletter | action (public) | email | Sends the double opt-in email |
getMyNewsletterStatus | authAction | none | Returns enabled and subscribed for the current user |
setMyNewsletterSubscription | authAction | subscribed | Subscribes or unsubscribes the current user |
sendNewsletter | admin | subject, content, sendAt (optional ISO date with offset) | Creates and sends or schedules a Resend broadcast |
listNewsletterBroadcasts | admin | none | Returns enabled and the audience's broadcasts |
See Server actions for every action in the project.
Customizing
- Confirmation email: edit
sendConfirmationEmailinlib/newsletter-actions.ts. The subject usessiteConfig.name. - Email template: edit
renderNewsletterHtmlinfeatures/newsletter/newsletter-format.ts. Keep the unsubscribe link. - Sender: broadcasts use
EMAIL_FROM. Change thefromfield insendNewsletterto use another address. - Form placement:
NewsletterFormis a client component. Render it anywhere, guarded bynewsletterEnabled()from a server component.