Modules
Newsletter
The newsletter module adds a subscriber capture form to any Stedefast site. Visitors enter their email, receive a HMAC-signed confirmation link, and are stored in D1. The site owner can view and export confirmed subscribers from the admin panel. Built-in double opt-in protects against fake signups. Optional Cloudflare Turnstile integration protects the form from bots.
Email delivery is via Resend (default) or Cloudflare Email Routing (free, no third-party account required).
Installation
pnpm add @stedefast/module-newsletter
Setup
// stedefast.config.ts
import { defineConfig } from "@stedefast/core";
import { NewsletterModule } from "@stedefast/module-newsletter";
export default defineConfig({
// ...
modules: [
NewsletterModule({
from: "hello@example.com",
siteName: "My Blog",
provider: "resend", // or "email-routing"
doubleOptIn: true, // default — require email confirmation
turnstile: false, // set true to add Turnstile to the form
redirectUrl: "/", // where to redirect after confirmation
confirmationSubject: "Confirm your subscription",
welcomeEmail: false, // set true to send a welcome email on confirm
}),
],
cloudflare: {
projectName: "my-site",
d1Databases: [{ binding: "NEWSLETTER_DB", databaseId: "YOUR_D1_DATABASE_ID" }],
},
});
Cloudflare bindings
| Binding | Type | Required | Purpose |
|---|---|---|---|
NEWSLETTER_DB |
D1 database | Always | Subscriber storage |
NEWSLETTER_SECRET |
Secret | Always | HMAC key for signing confirmation tokens |
RESEND_API_KEY |
Secret | When provider = "resend" |
Resend API email delivery |
SEND_EMAIL |
Email binding | When provider = "email-routing" |
CF Email Routing delivery |
TURNSTILE_SECRET_KEY |
Secret | When turnstile = true |
Server-side Turnstile verification |
Create the D1 database:
wrangler d1 create my-site-newsletter
Copy the database_id into your config, then declare the binding in wrangler.toml:
[[d1_databases]]
binding = "NEWSLETTER_DB"
database_name = "my-site-newsletter"
database_id = "YOUR_D1_DATABASE_ID"
[vars]
SITE_BASE_URL = "https://example.com"
[[secrets]]
name = "NEWSLETTER_SECRET"
name = "RESEND_API_KEY"
Running D1 migrations
Apply the bundled migration to create the newsletter_subscribers table:
wrangler d1 migrations apply my-site-newsletter --local # local dev
wrangler d1 migrations apply my-site-newsletter # production
Or use the Stedefast deploy command, which applies D1 migrations automatically:
stedefast deploy
Double opt-in flow
With doubleOptIn: true (the default), a subscription goes through three steps:
- Subscribe — visitor POSTs their email to
/_modules/newsletter/subscribe. The module inserts apendingrow and sends a confirmation email. - Confirm — visitor clicks the confirmation link in their email. The module verifies the HMAC token (48-hour expiry), updates
statustoconfirmed, and redirects to/?subscribed=1. - Unsubscribe — any email sent by your site can include an unsubscribe link pointing to
/_modules/newsletter/unsubscribe?token=...&email=.... Clicking it setsstatustounsubscribed.
Existing confirmed subscribers who re-submit are silently accepted (200 OK) without sending another email — this prevents revealing whether an address is already subscribed.
Pending subscribers are rate-limited to one confirmation resend per 5 minutes.
Adding the island to a page
Mount the island anywhere in a template:
// theme/templates/home.tsx
<div
data-island="NewsletterForm"
data-props={JSON.stringify({
showCount: true,
sourceUrl: page.url,
turnstileSiteKey: process.env.TURNSTILE_SITE_KEY,
})}
/>
The island is bundled by the asset pipeline to dist/islands/newsletter.js.
Subscriber count display
Setting showCount: true on the island fetches dist/data/newsletter/stats.json at page load and displays the number of confirmed subscribers:
Join 1,247 readers
The count is generated at build time by buildStaticExport and served from the CDN — no server request is made. Pass showCount: false (the default) to hide it.
Unsubscribe links in emails
Include an unsubscribe link in every email you send to subscribers. Generate the link by appending a signed token and the subscriber's email:
https://example.com/_modules/newsletter/unsubscribe?token=<token>&email=<email>
Tokens for unsubscribe links use a 30-day expiry. A subscriber who clicks the link is immediately set to unsubscribed and redirected to /?unsubscribed=1.
Because email clients render links as GET requests, the unsubscribe endpoint uses GET (not POST).
Admin panel
The admin panel is available at /admin/newsletter and has two tabs:
Subscribers — a table of all confirmed subscribers showing:
- Email address
- Confirmation date
- Source URL (the page where they subscribed)
The Export CSV button downloads all confirmed subscribers as a CSV file with columns: email, confirmed_at, source_url.
Pending — a list of pending signups older than 24 hours that have not clicked the confirmation link. Use this to identify and clean up stale pending rows.
Configuration reference
| Option | Type | Default | Description |
|---|---|---|---|
from |
string |
required | Sender email address for all outgoing emails |
siteName |
string |
required | Used in email subject lines and bodies |
provider |
"resend" | "email-routing" |
"resend" |
Email delivery provider |
doubleOptIn |
boolean |
true |
Require email confirmation before activating a subscription |
turnstile |
boolean |
false |
Add a Cloudflare Turnstile widget to the subscribe form |
redirectUrl |
string |
"/" |
Page to redirect to after clicking the confirmation link |
confirmationSubject |
string |
"Confirm your subscription" |
Subject line for the confirmation email |
welcomeEmail |
boolean |
false |
Send a welcome email immediately after confirmation |