Engineering

Building Blog Comments That Don't Get Spammed to Death

We added threaded comments, emoji reactions, and hCaptcha verification to the Lumo blog. Here's why bot protection was the first feature we built, not the last.

By Lumo EngineeringFeb 15, 20263 min read

We wanted blog comments. Simple, right? Text box, submit button, show the comment. Except the internet is full of bots that would love nothing more than to fill your comment section with cryptocurrency scams and suspiciously enthusiastic product reviews.

So before we wrote a single line of comment rendering code, we built the spam wall.

hCaptcha: Server-Side or Bust

Client-side captcha verification is theater. If the verification happens in the browser, a bot can just skip the widget and call your API directly. The token has to be verified server-side, and the comment insert has to be gated behind that verification.

Our architecture splits the flow into two parts:

// Public action — verifies captcha, then calls internal mutation
export const submitCommentWithCaptcha = action({
  args: {
    postSlug: v.string(),
    authorName: v.string(),
    content: v.string(),
    captchaToken: v.string(),
    parentId: v.optional(v.id("blogComments")),
  },
  handler: async (ctx, args) => {
    // 1. Verify with hCaptcha API
    const response = await fetch("https://api.hcaptcha.com/siteverify", {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: `response=${args.captchaToken}&secret=${process.env.HCAPTCHA_SECRET_KEY}`,
    });
    const result = await response.json();
    if (!result.success) throw new Error("Captcha verification failed");
 
    // 2. Insert via internal mutation (not publicly callable)
    await ctx.runMutation(internal.blogComments.insertBlogComment, {
      postSlug: args.postSlug,
      authorName: args.authorName,
      content: args.content,
      parentId: args.parentId,
    });
  },
});

The key detail: insertBlogComment is an internal mutation. You can't call it from the client. The only way to insert a comment is through the action that verifies the captcha first. Bots can't skip the verification because there's no direct path to the insert.

Threaded Replies

Comments support one level of nesting via a parentId field. We deliberately chose not to support infinite nesting — anyone who's moderated a Reddit thread knows that deeply nested conversations become unreadable on mobile screens.

The rendering uses a simple grouping strategy: top-level comments are fetched first, then replies are loaded per-thread. This keeps the initial page load fast (you see the conversation structure immediately) and lets users expand reply threads on demand.

Emoji Reactions

Blog posts get emoji reactions — a lightweight engagement signal that doesn't require composing a comment. The reaction system uses a simple counter table indexed by (postSlug, emoji), with rate limiting to prevent spam.

We made a deliberate UX choice: engineering posts get full comments. Blog posts get reactions only. The engineering audience is more likely to have substantive feedback worth threading. Blog readers just want to leave a quick signal that they found something useful.

Admin Moderation

The admin panel got a comment moderation dashboard with:

  • Approve/Hide/Delete actions per comment
  • Real-time comment statistics
  • Activity tracking for moderation auditing

Every comment starts as approved (we're optimistic about our readers), but the admin can hide or delete problematic ones instantly via the admin panel. Convex's reactive queries mean the comment disappears from the blog in real-time — no cache invalidation, no page rebuild.

The Dark Mode Captcha Problem

One detail that took longer than it should have: hCaptcha's widget respects a theme prop. If your site is in dark mode and the captcha widget is in light mode, it looks like a glowing white rectangle that screams "I AM A SECURITY MEASURE, PLEASE RESENT ME."

We sync the captcha theme with the site's dark mode state:

<HCaptcha
  sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY}
  theme={isDarkMode ? "dark" : "light"}
  onVerify={setCaptchaToken}
/>

Small detail. Big difference in perceived polish.


Abdul Rafay Founder, Syntax Lab Technology

Loading...