Building a Small Simple Comment System
Notes from building my own comments service, threads, replies, likes, moderation, and the browser security work around them.
What I built and why it’s tiny
I built a comments service for my own site and kept the scope small on purpose. Someone signs in, opens a thread, writes a comment, replies, leaves a like. That’s it. Behind that I still wanted moderation, session control, and browser security work that holds up.
Small buys me something specific here. I can hold the whole service in my head, read the request path end to end in one sitting, and fix a problem without hunting across six systems to find where it went sideways.
The rules I set early
I picked a few limits at the start and stuck to them. Every blog post gets one thread, sourced from the site RSS feed. Replies are comments with a parentCommentId and a depth. Markdown is allowed, raw HTML isn’t.
Every write goes through Origin checks, CSRF validation, and auth. No exceptions, no “well this one route is internal.”
Those limits are why the thing stays easy to work on. They cut off hidden scope, which for a comments tool matters more than the feature count.
How a request moves
The schema matters, but the request flow explains the service better.
Someone opens a post. The frontend asks the service to map the post slug to a thread, then loads that thread’s comments. If they’re signed in, the response also carries whether they liked each comment.
Login is GitHub OAuth with PKCE. The service stores temporary OAuth state, catches the callback, upserts the user, creates a session row, 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. Ordering the checks that way keeps a flood of junk requests from ever reaching Postgres, which does more for the service under load than any single check in the list.
The tables
Each one does one thing.
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 logged in once.
That’s the whole feature set. More tables wouldn’t make this safer or easier to run, so there aren’t more tables.
Deletes are soft first
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 live tables from filling up with junk.
Resolving threads against RSS
The resolve endpoint takes siteKey, resourceType, and resourceId and gives back a threadId. The upsert is boring. The check around it is the interesting part.
The service fetches the site’s RSS feed, pulls the valid slugs out, and only creates threads for posts that exist. Somebody throwing random slugs at the endpoint can’t stuff my database full of junk rows.
Why PKCE fits here
PKCE is the right shape for a public web login flow. The browser never holds a secret, and 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.
Validating where you get sent after login
Every OAuth flow needs somewhere safe to drop the user afterward. I validate returnTo against known blog origins plus the service origin.
Skip that check and your login flow doubles as an open redirect, which is not a trade I’m making for a comments box.
Sessions live in Postgres
The cookie is a pointer and nothing else: 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 on purpose. Revoking a session is a row update. Banning someone is a row update. Invalidating a session doesn’t need extra token rules or a denylist to go with it. That kind of boring is worth a lot when you’re the only person maintaining the thing.
Gating writes
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.
// 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 }}The CSRF part
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, since failure timing shouldn’t leak anything about the token’s 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 that 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 readable. No component leans on hidden state to find its token.
Where things break
Exotic attacks are not what shows up in the logs. Ordinary browser problems are. A new origin missing from the allowlist. One request forgetting the CSRF header. Cookies failing to cross the boundary after http and https got mixed. A write firing before the /v1/me request came back.
The narrow flow makes all of those quick to track down, 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. 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 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 you learn things. Spikes mean cold starts, slow database setup, or a big thread without a limit on it. Those are the numbers to watch, because a reader notices a slow p95 long before they notice a fast median.
Write latency
Writes cost more and that’s fine. They include the markdown render, the sanitizing, and the whole stack of checks.
On a blog, reads are the common path and they should stay cheap. A heavier write path is a fair trade for that.
Error rate
Day 4 is the interesting one. That bump lines up with an RSS fetch timing out during a deploy, which pushed resolve calls into the error column until the feed came back. Same day shows up in the resolve latency chart above.
The stuff that gave me trouble
None of it was the SQL. Everything that broke came from browser state and cross-origin rules.
Cookies across multiple origins
Same three questions every time. Is the blog on HTTPS? Is the service on HTTPS? Is the browser sending credentials?
A 401 that looks random usually isn’t. A cookie failed to cross the boundary and the request arrived anonymous.
CSRF token ordering
A frontend that posts before /v1/me resolves is supposed to fail, and it does. That surprised me the first time and then never again, because the strict write path is doing exactly what I asked it to.
Allowlist drift
Preview domains come and go, and every missing allowlist update makes writes fail. The fix is small each time, but it’s a good argument for keeping origin policy in one file instead of three.
Rate limits with more than one instance
The rate limiter right now is an in-memory map, which works until there’s more than one instance.
That one’s a known ceiling, not a bug. I’ll fix it when the traffic makes me.