Skip to content

Newsletter

Markdown

The newsletter block sits in the footer of every content page, with a heading and a blurb from newsletter. features.newsletter picks the mode: 'off', 'embed' for a provider’s own form, or 'd1' for a signup form that writes to your database.

  1. Paste the provider’s form.

    site.config.ts
    features: { newsletter: 'embed' },
    newsletter: {
    heading: 'New tools, weekly',
    blurb: 'One email a week. No spam.',
    embedHtml: '<form action="https://provider.example/subscribe" method="post"><input type="email" name="email" /><button>Subscribe</button></form>',
    },

    pnpm check:config answers site.config.ts OK — <name> (<url>).

  2. Build, and read the zero-JS line.

    Terminal
    pnpm build

    zero-js: <n> content pages, 0 islands, 0 external scripts — ok. An embed carrying <script src="…"> turns that into — fail and stops the build (see below).

  3. Deploy with pnpm deploy or a push.

What to check Where You should see
The block the footer of any content page heading, blurb, and the form (d1) or your embed
The row pnpm exec wrangler d1 execute DB --remote --command "SELECT count(*) AS n FROM subscribers WHERE unsubscribed_at IS NULL" n counting your test address
The count /admin/stats/ Newsletter subscribersn/a in any mode but d1
Zero JS the build log zero-js: … 0 external scripts — ok

embed renders newsletter.embedHtml verbatim under the heading and blurb, with nothing sanitised: a provider’s <form>, an <iframe> or an inline <script> all pass through. The one constraint is the build check in integrations/budget.ts: every page but /search/ must carry no <astro-island> and no <script src>, and the check fails the build with content pages must ship zero JavaScript, naming the pages. Inline scripts are counted and allowed; only a downloaded script fails. A provider snippet that loads its own JavaScript therefore cannot be embedded as given.

d1 is a plain <form method="post"> to /api/newsletter/ — the trailing slash matters, because /api/newsletter earns a 308 first — handled by src/pages/api/newsletter.ts with no JavaScript on either side. The endpoint checks the honeypot before anything else: with features.honeypot on the form carries a hidden company field, and a filled one gets the same “thanks” a person gets while nothing is written. Then the address: trimmed, lower-cased, validated, at most 254 characters. Then one statement, INSERT … ON CONFLICT DO NOTHING, so a repeat signup writes nothing, spends nothing of the D1 write quota, and is indistinguishable from a first one — the endpoint is not an oracle for who is on the list. The answer is a 303 to the page the form was on (a same-origin Referer, else the home page) with a fragment: #newsletter-thanks, #newsletter-invalid, or #newsletter-unavailable when D1 refused the write. All three messages are in the static HTML and :target reveals one; the address itself never enters the URL.

The table is subscribers(email, source, created_at, unsubscribed_at), the email as primary key. /admin/stats/ counts the rows with unsubscribed_at empty.

There is no unsubscribe flow. No route and no link in any mail: the starter sends no newsletter, only its transactional messages. The column exists for you. Set it by hand and the address leaves the count; a later signup from that address stays unsubscribed, because DO NOTHING never touches an existing row.

Terminal
pnpm exec wrangler d1 execute DB --remote --command "UPDATE subscribers SET unsubscribed_at = unixepoch() WHERE email = 'gone@example.com'"

Exporting the list for the tool that actually sends:

Terminal
pnpm exec wrangler d1 execute DB --remote --json --command "SELECT email, created_at FROM subscribers WHERE unsubscribed_at IS NULL ORDER BY created_at" > subscribers.json

created_at is unix seconds.

State Visitor sees /admin/ reports Fix
'off' (the default) no block; /api/newsletter/ answers 404 stats: n/a
'embed' with no embedHtml heading and blurb, no form stats: n/a set embedHtml
'embed' with a <script src> the build fails use the provider’s plain form or a hosted page
Honeypot filled “Thanks — you are on the list.”; nothing stored nothing
Bad address “That does not look like an email address.” nothing
D1 refuses the write (the free plan’s daily cap, say) “Sign-up is temporarily unavailable.” newsletter: signup failed in the Worker log; the report has no newsletter check wait, or Workers Paid
Field Default What it changes
features.newsletter 'off' 'off', 'embed' or 'd1'
features.honeypot true the hidden field on the d1 form
newsletter.heading 'Stay in the loop' the block’s heading
newsletter.blurb 'New {nouns} and updates, once a week. No spam.' the line under it
newsletter.embedHtml the embed markup, verbatim

See features and newsletter.

  • Directorysrc/
    • Directorycomponents/site/
      • Newsletter.astro the block, the three fragments, the honeypot
      • Footer.astro where it mounts
    • pages/api/newsletter.ts handleSignup(), the 303
    • db/schema.ts subscribers
    • templates/admin/StatsPage.astro the subscriber count
    • config/defaults.ts DEFAULT_NEWSLETTER
  • integrations/budget.ts the zero-JS check