For years I have been running this blog on Zola without any comment system. The static nature of Zola made adding comments non-trivial and I was not keen on third-party solutions like Disqus or Giscus. When I migrated to Astro (not yet published at the time of writing), the opportunity to build something custom finally presented itself.
The Requirements#
I wanted a solution that:
- Uses no external comment hosting service
- Requires manual approval before comments appear
- Notifies me via email when someone comments
- Protects against bots without annoying CAPTCHAs
- Does not require rebuilding the site for new comments
The last point was crucial. With static site generators, you typically need to rebuild and redeploy every time content changes. For comments, this is impractical.
The Stack#
After some research, I settled on:
- Cloudflare D1 for storing comments (SQLite at the edge)
- Astro Server Islands for dynamic comment loading
- Resend for email notifications
- Cloudflare Turnstile for bot protection (free, unlike reCAPTCHA)
The beauty of Server Islands is that the main article stays static and fast, while only the comments section loads dynamically. Search engines can still crawl the static content, and users see comments appear without a full page reload.
Database Schema#
The D1 schema is intentionally simple:
CREATE TABLE comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_slug TEXT NOT NULL,
parent_id INTEGER,
author TEXT NOT NULL,
content TEXT NOT NULL,
status TEXT DEFAULT 'pending',
created_at TEXT DEFAULT (datetime('now')),
token TEXT UNIQUE
);
CREATE INDEX idx_comments_post_status ON comments(post_slug, status);
CREATE INDEX idx_comments_parent ON comments(parent_id);
The token field is a random string used for approve/reject links in the
email. The parent_id allows single-depth threading - replies to comments,
but no replies to replies.
The Approval Flow#
When someone submits a comment:
- Turnstile verification runs server-side
- Comment is saved to D1 with
status: 'pending' - A unique token is generated for admin actions
- Resend sends me an email with the comment content and two links
The email looks something like this:
New comment on: some-post-slug
Author: John
---
This is the comment content...
---
[APPROVE] | [REJECT]
Clicking Approve updates the status to approved. Clicking Reject deletes
the comment entirely. No admin dashboard needed - everything happens via
email links.
Server Islands in Practice#
The comments component is wrapped with server:defer:
<CommentsSection server:defer postSlug={slug}>
<p slot="fallback">Loading comments...</p>
</CommentsSection>
This tells Astro to render the component on the server at request time, not at build time. The main page loads instantly from Cloudflare's CDN, then the comments section fetches separately. Users see "Loading comments..." briefly, then the actual comments appear.
The component itself is straightforward - it queries D1 for approved comments and renders them:
const { results: comments } = await db
.prepare(
"SELECT * FROM comments WHERE post_slug = ? AND status = 'approved'"
)
.bind(postSlug)
.all()
Local Development#
One gotcha: D1 bindings are not available at build time, only at request
time. For local development, the @astrojs/cloudflare adapter provides a
platform proxy that simulates the Cloudflare environment:
adapter: cloudflare({
platformProxy: {
enabled: true,
persist: ".wrangler/state",
},
}),
Running npm run dev now works with local D1. Data persists between
restarts in the .wrangler/state directory.
Turnstile Integration#
Turnstile is Cloudflare's free CAPTCHA alternative. It runs invisibly in most cases, only showing a challenge when it detects suspicious behavior. The verification happens server-side:
const turnstileResponse = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
body: new URLSearchParams({
secret: env.TURNSTILE_SECRET_KEY,
response: turnstileToken,
}),
}
)
const result = await turnstileResponse.json()
if (!result.success) {
return new Response(JSON.stringify({ error: "Bot verification failed" }))
}
I really did not like the 38 seconds forced "select all traffic lights" puzzles, but since I have no experience with Turnstile at this point we'll yet have to see how it turns out.
What About Newsletter?#
While I was at it, I added a newsletter subscription form using Resend Audiences. Same pattern: Turnstile verification, then add the email to a Resend Audience. Resend handles unsubscribe links automatically when sending broadcasts. The subscriber sees a simple "Subscribed!" message on the form.
Dependency Concerns#
Coming from Zola, I was worried about the npm ecosystem instability. Zola is a single Rust binary that barely changes. Astro has dozens of dependencies.
My mitigation strategy:
- Lock exact versions in
package.json(no^prefix) - Keep the dependency count minimal - only
@astrojs/cloudflareandresend - Use raw D1 queries instead of an ORM
- Avoid heavy integrations
The D1 and Turnstile code uses standard Web APIs, so it should remain stable for years. If Astro itself breaks in a future version, the core logic can be extracted.
Conclusion#
The final solution has exactly the features I wanted with minimal moving parts. Comments load dynamically without rebuilds, bot protection is invisible, and approval happens via email. Total new dependencies: two.
Sometimes the best approach is building exactly what you need instead of reaching for a third-party service. Enjoy!