Comments on a static blog — no backend, no login, all Cloudflare
I run a small Astro blog on Cloudflare Pages. It mixes developer write-ups with personal, everyday posts, so adding comments came with one hard constraint: no login wall. A GitHub-login widget like Giscus or Utterances would shut out every non-developer reader.
That ruled out the easy paths. Disqus is heavy and tracker-laden. Waline is genuinely good, but it wants a backend + database running outside Cloudflare — one more thing to operate. The blog already lives on Cloudflare Pages, so the goal became: keep comments inside the same stack. No login, no spam, no separate server.
Here's what I shipped — comments, likes, and moderation — entirely on Pages + D1 + Turnstile, with a Telegram bot as the moderation UI.
Architecture
Static Astro (dist) ── Cloudflare Pages
├─ /api/comments (Pages Function) → Turnstile verify → D1 insert (approved=0) → Telegram notify
├─ /api/likes (Pages Function) → D1 counter (POST +1 / DELETE -1)
└─ /api/telegram/webhook → approve / reject / delete (secret_token auth)
D1: comments, likes
The site is statically built. Anything dynamic is just a Pages Function hitting one D1 database. There is no origin server.
Comments: no login, pre-moderated
Spam protection without a login is Cloudflare Turnstile — a free, privacy-friendly CAPTCHA. The browser solves the challenge, and the Function verifies the token server-side before it touches the database.
Every comment is stored with approved = 0 and is not shown until I approve it. For a brand-new blog, that means it can never be papered over with spam — nothing is public until I say so.
The moderation UI is a Telegram bot
I didn't build an admin page. When a comment lands, the bot DMs me with inline buttons — [✅ Approve] [❌ Reject]. Approving flips approved = 1. The approved message then keeps a 🗑 Delete button, so I can remove an already-published comment from the same chat later.
The webhook is authenticated. Telegram sends an X-Telegram-Bot-Api-Secret-Token header (you set it via secret_token on setWebhook), and the Function rejects anything that doesn't match:
// setWebhook (placeholders — never commit real values)
// POST https://api.telegram.org/bot<YOUR_BOT_TOKEN>/setWebhook
// url=https://log.piyaklabs.com/api/telegram/webhook
// secret_token=<YOUR_WEBHOOK_SECRET>
if (env.TELEGRAM_WEBHOOK_SECRET) {
const got = request.headers.get("X-Telegram-Bot-Api-Secret-Token");
if (got !== env.TELEGRAM_WEBHOOK_SECRET) {
return new Response("Forbidden", { status: 403 });
}
}
Likes: a counter on a static site (and the bug I earned)
Likes are a D1 counter plus localStorage to remember "you liked this." First version: like → POST (+1), unlike → only clear localStorage. The bug: refresh, unlike locally, like again, and the server count climbs forever — because the server never saw the unlike.
The fix is to make it symmetric: like = POST (+1), unlike = DELETE (−1, floored at 0). No login means there's no perfect one-person-one-vote, but for a personal blog this is plenty.
The small stuff
- The comment form tells readers up front that comments appear after approval.
- Private posts get neither comments nor likes (reusing an existing
isPrivateflag). - I also dropped in Cloudflare Web Analytics — cookieless, no consent banner.
Why I like this shape
Pre-moderation plus Telegram-as-admin means I run zero extra infrastructure and moderate from my phone with one tap. Cost is $0, the stack is one thing, and there's no backend to keep alive.
If you're on Cloudflare Pages and want comments that feel self-hosted without running a server, this pattern is worth copying.
See it live at the bottom of any post: https://log.piyaklabs.com — leave a comment, or borrow the pattern for your own blog.
Top comments (0)