Adding Comments to a Static Blog

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 reply

Reads 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 drop

Validation

The Worker validates before touching the database:

  • Name is required, max 100 characters
  • Comment body is required, max 5,000 characters
  • If a parent_id is 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-comments

Copy 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 list

Then run the schema migration:

npx wrangler d1 execute blog-comments --remote --file=schema.sql

3. 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 below

ALLOWED_ORIGIN format. This must be the bare origin of your blog — the scheme and host with no trailing slash and no path:

https://yourdomain.com

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

5. 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 build

Tradeoffs

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