Newsletter
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.
-
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:configanswerssite.config.ts OK — <name> (<url>). -
Build, and read the zero-JS line.
Terminal pnpm buildzero-js: <n> content pages, 0 islands, 0 external scripts — ok. An embed carrying<script src="…">turns that into— failand stops the build (see below). -
Deploy with
pnpm deployor a push.
-
Switch the mode. The
subscriberstable is in the shipped migrations, so a site that has runpnpm cf:setupalready has it.site.config.ts features: { newsletter: 'd1' },newsletter: { heading: 'New tools, weekly', blurb: 'One email a week. No spam.' }, -
Deploy.
Terminal pnpm deploy -
Sign up once from the live footer. The browser comes back to the same page with
#newsletter-thanksin the URL and “Thanks — you are on the list.” in view.
Verify
Section titled “Verify”| 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 subscribers — n/a in any mode but d1 |
| Zero JS | the build log | zero-js: … 0 external scripts — ok |
How it works
Section titled “How it works”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.
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:
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.jsoncreated_at is unix seconds.
When it is off or degraded
Section titled “When it is off or degraded”| 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 |
Configuration
Section titled “Configuration”| 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