Introduction
Most "add a newsletter form" guides send you to a third-party script. This one keeps everything on your own edge: the pages, the form handler, and the data.
Serves static files and runs your API in one deploy.
SQLite at the edge. One table holds the signups.
Bot check without a puzzle for most visitors.
The request path is short: the form posts to /api/subscribe, the Worker verifies Turnstile, checks the rate limit, and writes one row.
Create the project
Start from any static site generator. This uses Astro, but the Worker doesn't care what produced dist/.
npm create astro@latest my-site -- --template minimal
cd my-site
npm i -D wrangler @cloudflare/workers-typesAdd a wrangler.jsonc at the root. The key line is run_worker_first: only API routes hit your code, everything else is served straight from the asset store.
{
"name": "my-site",
"main": "worker/index.ts",
"compatibility_date": "2026-10-01",
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"run_worker_first": ["/api/*"]
}
}npm run build && npx wrangler dev serves your site locally.Write the Worker
The Worker handles /api/* and hands everything else to the asset binding.
// worker/index.ts
interface Env { ASSETS: Fetcher; DB: D1Database; TURNSTILE_SECRET: string; SIGNUP_LIMIT: RateLimit }
export default {
async fetch(req, env): Promise<Response> {
const { pathname } = new URL(req.url);
if (pathname === '/api/subscribe' && req.method === 'POST') return subscribe(req, env);
return env.ASSETS.fetch(req);
},
} satisfies ExportedHandler<Env>; Store signups in D1
Create the database and a migration:
npx wrangler d1 create my-site
npx wrangler d1 migrations create my-site initWrangler prints a database_id. Add the binding to wrangler.jsonc, then write the table into the new migration file:
CREATE TABLE subscribers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
list TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (email, list)
);Then the insert. ON CONFLICT makes repeat signups harmless:
await env.DB.prepare(
`INSERT INTO subscribers (email, list, created_at) VALUES (?1, ?2, ?3)
ON CONFLICT (email, list) DO NOTHING`,
).bind(email, list, new Date().toISOString()).run();npx wrangler d1 migrations apply my-site --localnpx wrangler d1 migrations apply my-site --remoteProtect the form with Turnstile
Create a widget in the Cloudflare dashboard (Turnstile → Add widget) for your hostname. Put the site key in the page, and keep the secret on the Worker:
npx wrangler secret put TURNSTILE_SECRETVerify the token on every request before touching the database:
async function verify(env: Env, token: string, ip: string | null) {
const body = new FormData();
body.append('secret', env.TURNSTILE_SECRET);
body.append('response', token);
if (ip) body.append('remoteip', ip);
const r = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', body });
return ((await r.json()) as { success: boolean }).success;
} Add rate limiting
Turnstile stops bots. A rate limit stops one person hammering the endpoint. Add a binding:
"ratelimits": [{ "name": "SIGNUP_LIMIT", "namespace_id": "1001", "simple": { "limit": 5, "period": 60 } }]And check it first, because it's the cheapest check:
const { success } = await env.SIGNUP_LIMIT.limit({ key: req.headers.get('cf-connecting-ip') ?? 'unknown' });
if (!success) return Response.json({ ok: false, error: 'rate' }, { status: 429 });Deploy and verify
npm run build && npx wrangler deployThen prove each defense works, rather than assuming:
- A real signup from the page succeeds and shows up in
wrangler d1 execute my-site --remote --command "select * from subscribers". - A request with a fake Turnstile token returns
403. - Six quick requests in a row return
429on the sixth.