2026-07-08
How I Added Comments with Neon Postgres and Cloudflare Turnstile
I wanted comments on this blog, but I didn't want to bolt on a third-party widget. Those tools are fine, but they hand your visitors' data to someone else's servers. For this site, building it myself also felt more useful than writing about it in a resume bullet. So I built a small comment system directly into the site: a Next.js API route, a serverless Postgres database, and a free bot-check to keep the spam out.
This post is both a build log and a guide, in case you want to do the same thing on your own Next.js site.
Architecture
It's a short chain: a client component renders the comment form and existing comments, submits to a Next.js API route, and that route verifies the submission with Cloudflare before writing anything to a serverless Postgres database (Neon).
Comments.tsx (client) → app/api/comments/route.ts → Cloudflare Turnstile siteverify
→ Neon Postgres
The important design decision is that Turnstile verification happens server-side, inside the API route. Never trust a token the client claims is valid without checking it yourself against Cloudflare's endpoint.
Step by step
1. Get a Postgres database
I used Neon, a serverless Postgres provider with a generous free tier that sleeps when idle. If you're deploying on Vercel, the easiest path is connecting a Neon database from the project's Storage tab. It auto-populates a DATABASE_URL environment variable for you, so there's no connection string to copy around by hand.
2. Get a Cloudflare Turnstile site
Turnstile is Cloudflare's free CAPTCHA alternative. It's usually invisible to real visitors and only challenges suspicious traffic. Create a free Cloudflare account, add a Turnstile site for your domain (and localhost for local testing), and you'll get two keys:
- a site key (public, goes in the client-side widget)
- a secret key (private, used server-side to verify tokens)
3. The database table
Rather than a separate migration step, I create the table lazily the first time it's needed:
// lib/db.ts
export async function ensureCommentsTable() {
const sql = getSql();
await sql`
CREATE TABLE IF NOT EXISTS comments (
id SERIAL PRIMARY KEY,
post_slug TEXT NOT NULL,
author_name TEXT NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`;
}
This keeps setup to "add an environment variable" instead of "run a migration," which matters for a small side project.
4. The API route
app/api/comments/route.ts handles both reading and writing:
- GET returns all comments for a given post slug, oldest first.
- POST validates input lengths, verifies the Turnstile token against Cloudflare's
siteverifyendpoint, and only then inserts the comment.
The verification call looks like this:
const verifyResponse = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
secret: secretKey,
response: turnstileToken,
remoteip: request.headers.get("x-forwarded-for") ?? "",
}),
}
);
const verifyResult = await verifyResponse.json();
if (!verifyResult.success) {
return NextResponse.json(
{ error: "CAPTCHA verification failed. Please try again." },
{ status: 403 }
);
}
If that check fails, the request never reaches the database.
5. The client component
components/Comments.tsx is a "use client" component that:
- fetches existing comments for the post's slug on mount,
- renders the Turnstile widget from
@marsidev/react-turnstile, - and on submit, does an optimistic update. It appends the new comment to local state immediately, instead of waiting on a round trip and a re-fetch. It resets the Turnstile widget afterward so the same token can't be reused.
6. Environment variables
Three values, all documented in the repo's .env.example:
DATABASE_URL=
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=
Without them, the component degrades gracefully. It tells visitors comments aren't configured yet rather than crashing the page.
What I'd still change
This is a real build log, not a highlight reel, so a few honest caveats:
- No moderation. Comments post immediately. Fine for a low-traffic personal blog, not fine at scale.
- Turnstile is the only spam gate. There's no rate limiting beyond it. If this saw real traffic, I'd add per-IP throttling on the API route.
- No editing or deletion, by visitors or by me, yet.
The PM angle
"Just add comments" sounded like a small task until I scoped it: pick a database, decide how to keep spam out without a heavyweight CAPTCHA, and figure out how to make the UI feel instant instead of laggy. That's the same decomposition I'd do for any project at work. The feature request is never actually the whole task. The sub-decisions are where the real planning happens.
The full source is in the GitHub repo if you want to see it end to end.
Comments
Loading comments...