A static blog has a hard constraint: no server, so no place to store user input. Comments are the obvious casualty. The usual workarounds are: embed a third-party widget (Disqus, Giscus), use a hosted form service, or accept that comments just aren't a thing on your blog.
None of those felt right. Disqus is bloated and tracks your readers. Giscus ties comments to GitHub, which excludes anyone without an account. Hosted form services add another vendor. And skipping comments entirely is a miss — a comment thread on a technical post can be as useful as the post itself.
The alternative: write a small API that runs at the edge and bolt it onto the static site at build time.
The architecture
The setup has three parts:
- Cloudflare Worker — handles the comment API (two routes: read and submit)
- D1 — Cloudflare's serverless SQLite database, where comments are stored
- Resend — sends an email notification when a new comment arrives
The blog itself stays static. The Worker runs separately on Cloudflare's edge. The two are connected at build time by baking the Worker URL directly into the HTML of each post — no runtime configuration, no environment variables in the browser.
The database
D1 is SQLite, so the schema is straightforward:
CREATE TABLE IF NOT EXISTS comments (
id TEXT PRIMARY KEY,
post_slug TEXT NOT NULL,
parent_id TEXT REFERENCES comments(id),
name TEXT NOT NULL,
email TEXT,
body TEXT NOT NULL,
ip TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_comments_slug ON comments (post_slug, created_at);
CREATE INDEX IF NOT EXISTS idx_comments_ip ON comments (ip, created_at);parent_id enables one level of threaded replies — a top-level comment can have replies, but replies can't have replies. That's enough structure for a blog. ip is stored only for rate limiting and is never exposed in API responses. email is optional and also never returned to clients.
Two indexes: one for fetching all comments on a post ordered by date, one for the rate limit query.
The Worker
The Worker handles two routes:
GET /comments/:slug → return all comments for a post
POST /comments/:slug → submit a new comment or replyReads are open to anyone — comments are public data. Writes are restricted to requests originating from the blog's own domain, enforced via the Origin header checked against an ALLOWED_ORIGIN secret.
Rate limiting
Without a database or external service, rate limiting is just a query:
const { count } = await env.DB.prepare(
`SELECT COUNT(*) AS count FROM comments
WHERE ip = ? AND created_at > datetime('now', '-10 minutes')`,
).bind(ip).first();
if (count >= 5) {
return json({ error: 'Too many comments. Please wait a few minutes.' }, 429, cors);
}Five submissions per IP per ten minutes. Crude but effective for a personal blog.
Spam prevention
The form includes a honeypot field — a hidden input that no human would fill in:
<input name="hp" type="text" tabindex="-1" autocomplete="off" aria-hidden="true" style="display:none">On the server, if hp has any value, the submission is silently dropped. Bots that fill every visible field get a 200 OK response with no actual side effects — they think it worked.
if (hp) return json({ ok: true }, 200, cors); // silent dropValidation
The Worker validates before touching the database:
- Name is required, max 100 characters
- Comment body is required, max 5,000 characters
- If a
parent_idis supplied, it must belong to the same post (prevents cross-post reply injection)
Email notifications
After a comment is inserted, a notification goes out via Resend:
notify(env, { slug, name, body, isReply, siteUrl });The notify function is fire-and-forget — it's not awaited, and any error is swallowed. A failed email should never affect the response to the commenter.
await fetch('https://api.resend.com/emails', { ... }).catch(() => {});The secrets (RESEND_API_KEY, NOTIFY_EMAIL, NOTIFY_FROM, ALLOWED_ORIGIN) are set via wrangler secret put and never appear in source code.
The frontend
The comments UI is generated at build time by the build script. The Worker URL and post slug are baked into the HTML of each post as a JavaScript literal — there's no runtime config fetch, no environment variable injection, nothing to go wrong at load time.
function buildCommentsSection(slug, apiUrl) {
return `
<section class="comments" id="comments">
...
</section>
<script>
(function () {
var API = ${JSON.stringify(apiUrl + '/comments/' + slug)};
// all comment logic here
})();
</script>`;
}This function is called once per post during the build. If commentsUrl is empty in config.js, the function isn't called and the section is omitted entirely — disabling comments is a one-line config change followed by a rebuild.
The frontend itself is vanilla JavaScript: fetch comments on load, render them, wire up the submit form. No dependencies, no framework. Avatars are generated from initials with a hue derived from the name — a deterministic hash so the same person always gets the same colour without storing anything.
function hue(s) {
var h = 0;
for (var i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) & 0xffff;
return h % 360;
}Replies are rendered by fetching all comments for a post in a single request and grouping them client-side. Top-level comments first, then replies slotted under their parent by parent_id.
Deploying
1. Set up Resend
Create a free account at resend.com. The free tier allows 3,000 emails per month — more than enough for comment notifications.
Verify a sending domain. In the Resend dashboard, go to Domains → Add Domain and follow the DNS verification steps for your domain. This is required before you can send from a custom from address. Until your domain is verified, Resend will reject any email that doesn't originate from onboarding@resend.dev.
Once your domain is verified, create an API key under API Keys → Create API Key. Copy it — you'll need it in a moment.
2. Create the D1 database
cd worker
npx wrangler d1 create blog-commentsCopy the database_id from the output and paste it into worker/wrangler.toml. If you've already created the database and need to retrieve the ID:
npx wrangler d1 listThen run the schema migration:
npx wrangler d1 execute blog-comments --remote --file=schema.sql3. Set secrets
npx wrangler secret put RESEND_API_KEY # the API key from the Resend dashboard
npx wrangler secret put NOTIFY_EMAIL # address to receive comment notifications
npx wrangler secret put NOTIFY_FROM # verified sender, e.g. comments@yourdomain.com
npx wrangler secret put ALLOWED_ORIGIN # your blog's origin — see belowALLOWED_ORIGIN format. This must be the bare origin of your blog — the scheme and host with no trailing slash and no path:
https://yourdomain.comBrowsers send this exact string in the Origin header on cross-origin requests. The Worker compares the two with a strict equality check, so any deviation (a trailing slash, an extra path segment) will cause every comment submission to return 403 Forbidden.
If your blog is on Cloudflare Pages, the origin is whatever Pages assigned or your custom domain — for example https://your-project.pages.dev. You can verify the exact value by opening DevTools in your browser, submitting a comment, and inspecting the Origin header on the outgoing request.
4. Deploy the Worker
npx wrangler deploy5. Enable comments in the blog
Set commentsUrl in config.js to the Worker URL printed after deploy, then rebuild:
commentsUrl: 'https://blog-comments.yourname.workers.dev',npm run buildTradeoffs
No moderation. Comments go live immediately. For a low-traffic personal blog this is fine — the spam protection (honeypot + rate limiting + origin check) is enough. A high-traffic site would need a moderation queue.
One level of nesting. The schema supports parent_id but the UI only renders one level of replies. That covers the overwhelming majority of blog discussions without the complexity of fully recursive threading.
Email as the only notification. There's no dashboard, no admin panel. New comments show up in your inbox. If you want to reply, you post a comment like anyone else. Simple.
D1 is not free at scale. The free tier covers 5 million reads and 100,000 writes per day — plenty for a personal blog. The Worker free tier allows 100,000 requests per day. Both ceilings are well above what a personal site will ever hit.
No analytics on comments. The Worker logs nothing beyond what Cloudflare captures automatically. If you want to know which posts get the most comments, query D1 directly.
What it looks like in practice
The entire Worker is about 150 lines of JavaScript. The build-time HTML generator is another 130 lines. No npm packages, no build step for the Worker itself (Wrangler handles bundling), no runtime dependencies beyond the Cloudflare platform.
For a static blog that wants comments without ceding control to a third party, this is a reasonable amount of complexity. The moving parts are all visible, the data lives in a database you own, and the whole thing can be torn out by clearing one config value.
Comments