Building a Small Simple Comment System
Notes from building my own comments service, threads, replies, likes, moderation, and the browser security work around them.
Overview
I built this comments service for my own site, and I kept the scope small on purpose. A reader signs in, opens a thread, writes a comment, replies, leaves a like. That’s the product. Behind it, though, I still wanted real moderation, session control, and browser security work that holds up over time.
Small doesn’t mean weak here. Small means I can hold the entire service in my head, audit the request path in one sitting, and fix problems without hunting across six systems.
What Small Means Here
I set a few rules early and stuck to them. Each blog post gets exactly one thread, sourced from the site RSS feed. Replies are just comments with a parentCommentId and a depth. Markdown is allowed, raw HTML is not.
And every single write goes through Origin checks, CSRF validation, and auth. No exceptions.
Those limits are what keep the thing maintainable. They cut off hidden scope, which for a comments tool matters way more than feature count.
Request Flow
The schema matters, but honestly the request flow explains the service better.
A reader opens a post. The frontend asks the service to map the post slug to a thread, then loads the comments for that thread. If the reader is signed in, the response also carries whether they liked each comment.
Login is GitHub OAuth with PKCE. The service stores temporary OAuth state, receives the callback, upserts the user, creates a session row, and sets a session cookie.
Every write walks the same gate, in the same order:
- Check the Origin.
- Check the CSRF token.
- Check the session.
- Apply rate limits.
- Run the write.
Cheap rejection first, stateful work last. That ordering is doing more security work than any individual check.
Data Model
Each table has one narrow job.
User stores GitHub identity and moderation flags. Session stores server-side session state, including expiresAt, revokedAt, and lastUsedAt.
Thread maps a (siteKey, resourceType, resourceId) tuple to one thread. Comment stores the parent pointer, depth, markdown body, rendered HTML, and edit or delete timestamps.
CommentReaction stores likes with a unique (commentId, userId, reaction) key. OAuthState holds short-lived PKCE state with codeVerifier and returnTo. PrebannedUser blocks identities before they’ve even logged in once.
That covers the whole feature set. I could add more tables, but more tables wouldn’t make this service safer or easier to run, so I haven’t.
Soft Delete And Cleanup
Deletes start soft. Each comment row stores deletedAt and deletedBy, and a cron job hard deletes anything older than 72 hours.
That window gives moderators room to react without instant data loss, and the cron keeps the active tables from silting up over time.
RSS-Gated Thread Resolution
The resolve endpoint takes siteKey, resourceType, and resourceId and returns a threadId. The upsert itself is ordinary. The guardrail around it is the interesting part.
The service fetches the site’s RSS feed, pulls the valid slugs, and only creates threads for posts that actually exist. So someone throwing random slugs at the endpoint can’t fill my database with junk rows.
Why PKCE Fits
PKCE is the right shape for a public web login flow like this one. The browser never holds a secret, but the callback can still prove it belongs to the flow that started earlier.
The start route generates state, codeVerifier, and codeChallenge, stores the verifier and return path in OAuthState, then redirects to GitHub with the state and challenge.
The browser stays dumb, and the sensitive exchange stays on the server. That’s exactly the split you want.
Return Path Validation
Every OAuth flow needs somewhere safe to send the user after login. I validate returnTo against known blog origins plus the service origin.
Skip that check and your login flow doubles as an open redirect. Not a trade I’m making.
Server-Side Sessions
Sessions live in Postgres, and the cookie is nothing but a pointer: lh_comments_session=<uuid>.
On each authenticated request, the service reads the cookie, loads the session row, checks revocation, checks expiry, and returns the user. lastUsedAt updates in the background.
I picked this over JWTs deliberately. Revocation is a row update. Banning someone is a row update. Session invalidation doesn’t need extra token rules or a denylist. Boring wins.
Mutation Gating
Every write checks Origin, even with CORS configured. In production the service demands an Origin header and rejects anything outside the allowlist. The server owns the write boundary, full stop.
// Pseudocode shaped like the real route guardexport async function mutationAllowed(request: NextRequest) { const origin = request.headers.get('origin')
if (env.NODE_ENV === 'production') { if (!origin) return { ok: false, code: 'MUTATION_ORIGIN_REQUIRED' } if (!isAllowedOrigin(origin)) return { ok: false, code: 'MUTATION_ORIGIN_NOT_ALLOWED' } } else { if (origin && !isAllowedOrigin(origin)) { return { ok: false, code: 'MUTATION_ORIGIN_NOT_ALLOWED' } } }
// CSRF check happens here too return { ok: true }}CSRF Mechanism
CSRF protection is a cookie plus a request header. The cookie stores csrf_token=<random>, and the client sends the same value back in X-CSRF-Token.
The server checks presence, equal length, and constant-time equality, because failure timing shouldn’t leak anything about token shape.
const CSRF_COOKIE = 'csrf_token'
export async function verifyCsrf(request: NextRequest) { const cookieToken = (await cookies()).get(CSRF_COOKIE)?.value const headerToken = request.headers.get('x-csrf-token')
if (!cookieToken || !headerToken) return false if (cookieToken.length !== headerToken.length) return false
// Constant-time compare avoids timing leaks return crypto.timingSafeEqual( Buffer.from(cookieToken), Buffer.from(headerToken) )}How The Client Gets The Token
The /v1/me endpoint returns the current user plus a csrfToken. The client calls it on load, keeps the token in memory, and attaches it to every write.
That keeps the write path explicit. No component is depending on hidden state to get its token.
Where Failures Come From
Most failures turn out to be ordinary browser problems, not exotic attacks. A new origin missing from the allowlist. One request forgetting the CSRF header. Cookies quietly not crossing the boundary after http and https got mixed. A write firing before the /v1/me request finished.
The narrow flow makes all of these easy to debug, and once the request path is solid, the work gets dull in the best possible way.
Read Latency
One chart carries both the median and the tail here. Hit the Δ Compare toggle to shade the gap between p50 and p95, or open the table view to read the raw days.
Most of the read wins came from just doing less work. The service returns bodyHtml instead of rendering markdown on every client load. Likes get grouped into one query. Response shapes stay consistent, so the frontend never needs follow-up fetches for a normal list view.
The tail is where the truth lives. Spikes usually mean cold starts, slow database setup, or a large thread without a proper limit. Watch those numbers, because readers feel tail latency before they feel anything else.
Write Latency
Writes cost more, and that’s fine. They include the markdown render, sanitizing, and the full stack of checks.
On a blog, reads are the common path and they should stay cheap. A heavier write path is a completely acceptable trade for that.
Error Rate
That day 4 bump matches the failures I’d expect in practice: an RSS fetch hiccup, an allowlist mismatch after a domain change, or a client request missing credentials: 'include'.
Problems I Hit In Practice
The hard parts were never the SQL. Almost everything that actually broke came from browser state and cross-origin rules.
Cookies Across Multiple Origins
The debugging checklist here was always the same three questions. Is the blog on HTTPS? Is the service on HTTPS? Is the browser actually sending credentials?
Most “random” 401s aren’t random at all. A cookie failed to cross the boundary, every time.
CSRF Token Ordering
A frontend that posts before /v1/me resolves is supposed to fail, and it does. That failure feels surprising exactly once, and then the strict write path makes sense forever after.
Allowlist Drift
Preview domains come and go, and every missing allowlist update makes writes fail. The fix is always small, but it’s a good reminder that origin policy needs one source of truth, not three.
Rate Limits In Multi-Instance Setups
The current rate limiter is an in-memory map, which works great right up until there’s more than one instance.
Closing
This project stays small through a handful of unglamorous choices: simple identity, server-side sessions, guarded writes, sanitized markdown, and moderation built in from the start.
And that was the goal the whole time. A comments service that does the job, stays understandable, and doesn’t turn routine maintenance into a weekend project.