Designing a comment system that survives contact with readers
Most "blog with comments" examples are tutorials in disguise: they demonstrate the happy path and leave the sharp edges for you to discover in production. This site started from one of those examples, so this post documents what changed and why.
The three problems
A comment system is not one feature. It is three, and they fail independently:
- Identity — who is allowed to write, and how do you know?
- Storage — where does the text live, and can you delete it precisely?
- Moderation — what happens when the answer to (1) is "someone annoying"?
The original template solved all three, but each with a shortcut that only holds up under light use.
Identity: the token never needs to reach the browser
The template used a single-page-app SDK, which meant the browser held an access
token and forwarded it to the API on every request. The API then called Auth0's
/userinfo endpoint to find out who was calling.
That works, but it puts a bearer token somewhere it does not need to be. This
site uses the server-side SDK instead: the session lives in an encrypted,
HttpOnly cookie, and the route handler reads it directly.
// app/api/comments/[slug]/route.ts
const viewer = await getSessionUser();
if (!viewer) {
return NextResponse.json({ error: "Sign in first." }, { status: 401 });
}
No token in localStorage, no /userinfo round trip, and no chance of
accidentally logging the credential.
Storage: delete by id, not by value
This is the one that quietly corrupts data. The template stored comments in a Redis list and deleted them like this:
await redis.lrem(url, 0, JSON.stringify(comment));
That removes every element whose serialised form matches exactly. If the stored JSON and the reconstructed JSON differ by one byte — a key order, an added field, a timestamp precision — the delete silently succeeds and removes nothing. It also cannot distinguish two identical comments.
The fix is to stop treating the payload as the identifier. A Redis hash keyed by comment id makes deletion exact and single:
async remove(slug, id) {
const removed = await redis.hdel(`comments:${slug}`, id);
return removed > 0;
}
Authorisation: decide on the server, tell the client the answer
The template shipped each comment's author sub and email address to every
visitor, then let the client decide which delete buttons to render. The button
was hidden, but the API trusted the same client-supplied object.
Two rules here instead:
- The API never returns
subor email. It returnscanDelete, computed server-side from the session and the stored comment. - The delete endpoint re-checks ownership from storage. A hidden button is a UI affordance, never a permission.
const canDelete = isAdmin(viewer) || viewer.sub === comment.author.sub;
if (!canDelete) {
return NextResponse.json({ error: "Not allowed." }, { status: 403 });
}
Where comments are keyed
The template decided which post a comment belonged to by reading the
Referer header. That is a client-controlled value: any caller can set it to
another post's URL and read or write that thread.
Keying by the slug in the route path is not clever, but it is unambiguous:
export async function GET(
_request: Request,
{ params }: { params: Promise<{ slug: string }> },
) {
const { slug } = await params;
if (!isValidSlug(slug)) {
return NextResponse.json({ error: "Unknown post." }, { status: 400 });
}
// ...
}
The part that is genuinely hard
None of the above is difficult. The difficult part is what the code cannot do for you:
| Concern | Handled by | Still your job |
|---|---|---|
| Spam | rate limiting | reading the queue |
| Abuse | delete + ban the account | deciding when to ban |
| GDPR deletion | delete by id | answering a deletion request |
| Notification | — | noticing comments exist |
Sign-in requirement is the cheapest spam control that actually works, which is why most platforms converge on it. Everything past that is attention, not code.
A comment system is a small piece of software and a long-term commitment to reading what people write to you.
If you are building one: pick the identity model first. It constrains everything else.
Comments
Loading comments…