A New Recipe for Jekyll Comments

22 Mar 2025 #Dev

I've always wanted to implement a custom comment box in my Jekyll blog without relying on third-party services like Disqus or the GitHub API. Since I don't get many visitors, security isn't a major concern, and I don't plan to switch from static pages anytime soon, I decided to build my own solution. For this, I'll be using Supabase for the database and Cloudflare Workers for the serverless logic, keeping everything simple and cost-free.

In this blog post, I'll walk you through the process of implementing the comment box, handling database interactions with Supabase, and using Cloudflare Workers to manage the server-side logic. You can choose other databases as well. It's just that I like Supabase, but it tends to auto-turn off every week or two if inactive, but overall, I find it reliable.

Note that I assume you're already familiar with the basics of Jekyll, as this post is quite abstract. If you'd like the full code, feel free to ask in the comments.

1.

Intro

So, the initial idea I came up with involved exposing keys, but yeah, we can do better than that, and I doubt it's worth discussing anyway. For this one, the steps are pretty much the same as you'd think of if asked to create a chat box, haha:

  • Create a simple backend with Python or Node.js and host it somewhere.
  • Use Supabase for the database because of its simplicity and features.
  • Choose a hosting service. I went with Cloudflare Workers for its free tier. And most importantly 100k requests per day is more than enough. I doubt my blog would use even 0.01% of that.

Here are some alternatives, though:

Service Best For Free Plan Limits
Render Full backend (Node.js, Python) 750 hours/month
Vercel Fast serverless functions 1M / billing cycle
Railway Full backend + Free database 500 hours/month
Cloudflare Workers Superfast serverless API 100,000 requests/day
Netlify Frontend + Serverless functions Similar to Vercel I guess
2.

Implementation

Create Database

So, the first thing we need to do is create a database. Like I mentioned earlier, I'm using Supabase. Go to supabase.io and create an account. After that, create a new project. Once the project is created, you'll be taken to the dashboard. From there, you can create a table using the table editor or the SQL editor. For now, I'll use the table editor.

Click on the table editor, and you'll see an option to "Create a new table." Just give your table a name and add some columns. Here's what mine will look like:

Comments:
  • id (auto)
  • author (text, not nullable)
  • created_at (auto, timestamp with timezone)
  • content (text)
  • post_slug (text)

Ah, also, you'll need to turn off RLS (Row-Level Security). I didn't research much about this, but I think it causes some issues for some reason.

Create API

Steps are pretty simple. Visit the Cloudflare page and create an account. After signing in, you'll see an option called "Workers & Pages." Click on "Create" and start from a template. You can use the "Hello World" template. After that, I guess it will take you to the editor. If not there must be an option somewhere around. Find it.

There are a few things we need to worry about when creating the API. One of this is the CORS policy. We need to allow our domain to access the API. This can be done by adding the following code to the editor:

const corsHeaders = {
    "Access-Control-Allow-Origin": "*",  // Allow all domains (Change if needed)
    "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type, Authorization"
};

Another thing to consider is filtering out explicit words. Also, it's better to add restrictions on the username and message in the backend rather than the frontend to avoid many issues (you know what I meant right. hehe). With all this in mind, we can now add the following code to the supabase editor:

Expand for full code
const SUPABASE_URL = "XXX";
const SUPABASE_ANON_KEY = "XXX-XXX";

// CORS headers (Fixes CORS issue)
const corsHeaders = {
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type, Authorization"
};

// Bad Words List (Extended)
const badWordsList = [
    "word1", "word2", "word3", ...
];

// Normalize text to prevent bypassing (removes spaces & symbols)
function containsBadWords(text) {
    const normalizedText = text.toLowerCase().replace(/[^a-zA-Z0-9]/g, "");
    return badWordsList.some(word => normalizedText.includes(word));
}

// Handles all incoming requests
async function handleRequest(request) {
    if (request.method === "OPTIONS") {
    return new Response(null, { headers: corsHeaders });
    }

    if (request.method === "GET") {
    return fetchComments(request);
    } else if (request.method === "POST") {
    return submitComment(request);
    } else {
    return new Response("Method Not Allowed", { status: 405, headers: corsHeaders });
    }
}

// Fetch comments for a specific post
async function fetchComments(request) {
    const url = new URL(request.url);
    const post_slug = url.searchParams.get("post_slug");

    if (!post_slug) {
    return new Response(JSON.stringify({ error: "Missing post_slug" }), {
        status: 400,
        headers: { "Content-Type": "application/json", ...corsHeaders }
    });
    }

    const response = await fetch(`${SUPABASE_URL}/rest/v1/comments?post_slug=eq.${post_slug}&select=*`, {
    headers: {
        "apikey": SUPABASE_ANON_KEY,
        "Authorization": `Bearer ${SUPABASE_ANON_KEY}`,
        "Content-Type": "application/json"
    }
    });

    const data = await response.json();
    return new Response(JSON.stringify(data), { status: 200, headers: { "Content-Type": "application/json", ...corsHeaders } });
}

// Submit a new comment
async function submitComment(request) {
    try {
    const { author, comment, post_slug } = await request.json();

    // Validate input
    if (!author || !comment || !post_slug) {
        return new Response(JSON.stringify({ error: "Missing fields." }), {
        status: 400,
        headers: { "Content-Type": "application/json", ...corsHeaders }
        });
    }

    if (author.length > 15 || !/^[A-Za-z0-9]+$/.test(author)) {
        return new Response(JSON.stringify({ error: "Invalid name. Must be 1-15 alphanumeric characters." }), {
        status: 400,
        headers: { "Content-Type": "application/json", ...corsHeaders }
        });
    }

    if (comment.length < 5 || comment.length > 500) {
        return new Response(JSON.stringify({ error: "Comment must be between 5-500 characters." }), {
        status: 400,
        headers: { "Content-Type": "application/json", ...corsHeaders }
        });
    }

    // Block bad words
    if (containsBadWords(comment)) {
        return new Response(JSON.stringify({ error: "Inappropriate language detected." }), {
        status: 400,
        headers: { "Content-Type": "application/json", ...corsHeaders }
        });
    }

    // Save comment to Supabase
    const response = await fetch(`${SUPABASE_URL}/rest/v1/comments`, {
        method: "POST",
        headers: {
        "apikey": SUPABASE_ANON_KEY,
        "Authorization": `Bearer ${SUPABASE_ANON_KEY}`,
        "Content-Type": "application/json"
        },
        body: JSON.stringify({ author, content: comment, post_slug })
    });

    return new Response(JSON.stringify({ message: "Comment added" }), {
        status: response.status,
        headers: { "Content-Type": "application/json", ...corsHeaders }
    });

    } catch (error) {
    return new Response(JSON.stringify({ error: "Internal Server Error", details: error.toString() }), {
        status: 500,
        headers: { "Content-Type": "application/json", ...corsHeaders }
    });
    }
}

addEventListener("fetch", event => {
    event.respondWith(handleRequest(event.request));
});

This is a basic implementation of the API. You can add more features like rate limiting, spam protection, avatars, and even replying to comments. But for now, this is enough.

Now, on the frontend, we have two tasks:

  1. 1. Fetch comments

We can do this by using the following code:

const API_URL = "cloudflare-worker-url"; // Replace with the actual Cloudflare Worker URL
const POST_SLUG = "{{post.slug}}; // Replace with the actual post slug

async function fetchComments() {
    try {
        const response = await fetch(`${API_URL}?post_slug=${POST_SLUG}`);
        if (!response.ok) {
            throw new Error("Failed to fetch comments");
        }
        const data = await response.json();
        console.log(data); // Logs the JSON response
    } catch (error) {
        console.error("Error fetching comments:", error);
    }
}

For the post slug, you can utilize the front matter of the post. To do that, add a `slug` to the front matter of the blog, like this for example:

---
layout: post
title: "Title"
date: 2025-03-22
last_updated: 2025-03-22
category: Jekyll
tags: [jekyll, comment-box, cloudflare]
slug: jekyll-comment-box
---

Then, to access it in the comment box, you can simply use {{post.slug}}.

2. Submit comments

To do this, you can do something similar to:

async function submitComment(author, comment) {
    try {
        const response = await fetch(API_URL, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ author, comment, post_slug: POST_SLUG })
        });
        
        const result = await response.json();
        if (!response.ok) {
            throw new Error(result.error || "Error submitting comment");
        }
        
        console.log("Comment submitted successfully");
        fetchComments(); // Refresh comments after submission
    } catch (error) {
        console.error("Error submitting comment:", error);
    }
}

3.

Conclusion

And that's it! You now have a custom comment box for your Jekyll blog. You can further customize it by adding features like markdown, replies, and even user authentication. The possibilities are endless, and you can make it your own ✌(-‿-)✌




Add a Comment

Markdown Logo

Comments