Stedefast

Modules

Newsletter

4 min read

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:

  1. Subscribe — visitor POSTs their email to /_modules/newsletter/subscribe. The module inserts a pending row and sends a confirmation email.
  2. Confirm — visitor clicks the confirmation link in their email. The module verifies the HMAC token (48-hour expiry), updates status to confirmed, and redirects to /?subscribed=1.
  3. Unsubscribe — any email sent by your site can include an unsubscribe link pointing to /_modules/newsletter/unsubscribe?token=...&email=.... Clicking it sets status to unsubscribed.

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.

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