Skip to content
Knsept
Free playbook

Ship a static site with an email signup on Cloudflare Workers

One Worker serves your static site and a tiny API: a D1 table for signups, Turnstile against bots, and rate limiting. This is how knsept.com runs.

Intermediate 28 min 7 steps Edge & DeployJavaScript

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.

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-types

Add 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/*"]
  }
}
You’re ready
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 init

Wrangler 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 --local
You’re ready
Your table exists both locally and on Cloudflare.

Protect 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_SECRET

Verify 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 deploy

Then prove each defense works, rather than assuming:

  1. A real signup from the page succeeds and shows up in wrangler d1 execute my-site --remote --command "select * from subscribers".
  2. A request with a fake Turnstile token returns 403.
  3. Six quick requests in a row return 429 on the sixth.
Tutorial complete
You have a signup form with no third-party scripts, bot protection, and data you own.