Static sites are fast, secure, and simple to deploy. But they lack one thing out of the box: interactivity. Comments, likes, and other dynamic features traditionally require a backend.

This tutorial walks through how I built a comments and likes system for this site using Cloudflare’s serverless stack: D1 (SQLite database), Pages Functions (serverless API), and Turnstile (spam protection).

No external services. No third-party tracking. Everything runs on Cloudflare’s edge network.

TL;DR

For those short on time, here is the quick version:

What you need:

  • Cloudflare account with Pages project
  • D1 database with comments, likes, and like_records tables
  • Turnstile widget (Site Key for frontend, Secret Key for backend)
  • Pages Function at /functions/comments.ts
  • Hugo partial to render the UI

Critical gotchas that will waste your time:

  1. Add pages_build_output_dir = "public" to wrangler.toml or Pages ignores your D1 binding
  2. Define Turnstile callbacks on window object before loading the script, use render=explicit
  3. Place commentsApiUrl and turnstileSiteKey before any [params.subsection] blocks in hugo.toml
  4. Use the Turnstile Secret Key (not Site Key) for the TURNSTILE_SECRET environment variable
  5. Redeploy after changing environment variables

Enable on any post:

1
comments: true

Moderate via D1 console:

1
2
SELECT * FROM comments WHERE approved = 0;
UPDATE comments SET approved = 1 WHERE id = 1;

Now read on for the full story, including how each of those gotchas burned me.

Why This Approach

Before building, I considered the alternatives:

Disqus - Easy to set up, but adds tracking scripts, ads, and external dependencies. Not suitable for a privacy-conscious site.

giscus/utterances - GitHub-based, which is elegant for developer blogs. But it requires visitors to have GitHub accounts.

Self-hosted solutions - Full control, but requires managing servers, databases, and scaling.

Cloudflare’s stack offers a middle ground: fully self-hosted data, no servers to manage, generous free tier, and tight integration with Pages deployments.

Architecture Overview

The system has three components:

  1. D1 Database - Stores comments and like counts in SQLite
  2. Pages Function - Handles API requests at /comments
  3. Hugo Partial - Renders the UI and handles form submission

When a visitor loads a post with comments enabled, the page fetches existing comments and like counts from the API. When they submit a comment or click like, the form posts to the same endpoint.

Turnstile provides invisible spam protection without annoying CAPTCHAs.

Setting Up D1

D1 is Cloudflare’s serverless SQLite database. To create one:

  1. Go to Cloudflare Dashboard
  2. Navigate to Workers and Pages, then D1
  3. Click Create database
  4. Name it something descriptive (e.g., site-comments)

Once created, you need to set up the schema. In the D1 console, run these statements one at a time:

1
2
3
4
5
6
7
8
9
CREATE TABLE IF NOT EXISTS comments (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  slug TEXT NOT NULL,
  name TEXT NOT NULL,
  email TEXT,
  comment TEXT NOT NULL,
  approved INTEGER DEFAULT 0,
  created_at TEXT DEFAULT (datetime('now'))
)
1
CREATE INDEX IF NOT EXISTS idx_comments_slug ON comments(slug)
1
2
3
4
CREATE TABLE IF NOT EXISTS likes (
  slug TEXT PRIMARY KEY,
  count INTEGER DEFAULT 0
)
1
2
3
4
5
6
CREATE TABLE IF NOT EXISTS like_records (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  slug TEXT NOT NULL,
  ip_hash TEXT NOT NULL,
  created_at TEXT DEFAULT (datetime('now'))
)
1
CREATE INDEX IF NOT EXISTS idx_like_records_lookup ON like_records(ip_hash, slug)

The approved field enables moderation. Comments are hidden until manually approved. The like_records table stores hashed IPs to prevent duplicate likes without storing actual IP addresses.

Setting Up Turnstile

Turnstile is Cloudflare’s privacy-preserving alternative to CAPTCHA. To set it up:

  1. Go to Cloudflare Dashboard
  2. Navigate to Turnstile
  3. Click Add widget
  4. Configure the widget:
    • Widget name: Something descriptive
    • Hostnames: Add your domain (both with and without www)
    • Widget mode: Managed (recommended)
  5. Save and copy both keys:
    • Site Key (public, goes in your Hugo config)
    • Secret Key (private, goes in Pages environment variables)

The Managed mode handles most visitors invisibly, only showing a challenge when behavior seems suspicious.

Creating the Pages Function

Pages Functions live in a /functions directory at your project root. Create a file at functions/comments.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
interface Env {
  DB: D1Database;
  TURNSTILE_SECRET: string;
}

async function verifyTurnstile(
  token: string,
  ip: string,
  env: Env
): Promise<boolean> {
  const response = await fetch(
    'https://challenges.cloudflare.com/turnstile/v0/siteverify',
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        secret: env.TURNSTILE_SECRET,
        response: token,
        remoteip: ip,
      }),
    }
  );
  const result = await response.json() as { success: boolean };
  return result.success;
}

function sanitize(str: string | undefined): string {
  if (!str) return '';
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;');
}

The function handles four operations:

GET comments - Returns approved comments for a given slug GET likes - Returns like count for a given slug POST comment - Validates Turnstile, sanitizes input, inserts with approved=0 POST like - Validates Turnstile, checks for duplicate via hashed IP, increments count

The full implementation handles routing via query parameters (?type=likes for likes, default for comments) and includes proper error handling.

Binding D1 to Pages

For the function to access D1, you need to create a binding:

  1. Go to your Pages project in the Cloudflare Dashboard
  2. Navigate to Settings, then Functions
  3. Scroll to D1 database bindings
  4. Add a binding:
    • Variable name: DB
    • D1 database: Select your database

You also need to add the Turnstile secret:

  1. In the same Settings area, go to Variables and Secrets
  2. Add a new variable:
    • Name: TURNSTILE_SECRET
    • Value: Your Turnstile secret key
    • Check Encrypt

Hugo Configuration

Add the Turnstile site key to your Hugo config:

1
2
3
[params]
  commentsApiUrl = "/comments"
  turnstileSiteKey = "your-site-key-here"

The commentsApiUrl points to /comments because Pages Functions automatically map functions/comments.ts to that path.

Building the Frontend

The Hugo partial renders the comments UI and handles JavaScript interactions. Key elements:

Turnstile widget - Loads the challenge and provides a token on success Like button - Fetches current count on load, posts increment on click Comment form - Name, optional email, comment text Comments list - Renders approved comments with author and date

The JavaScript waits for Turnstile verification before enabling the submit button. This prevents spam bots from submitting forms without completing the challenge.

1
2
3
4
<div class="cf-turnstile"
     data-sitekey="{{ site.Params.turnstileSiteKey }}"
     data-callback="onTurnstileSuccess">
</div>

When the callback fires, the form becomes interactive.

Moderation Workflow

Comments arrive with approved=0 and do not appear on the site. To moderate:

  1. Go to D1 in the Cloudflare Dashboard
  2. Open your database console
  3. View pending comments:
1
SELECT * FROM comments WHERE approved = 0
  1. Approve legitimate comments:
1
UPDATE comments SET approved = 1 WHERE id = 123
  1. Delete spam:
1
DELETE FROM comments WHERE id = 456

This is manual but straightforward. For higher volume sites, you could build an admin interface or use Cloudflare Workers to create a protected moderation API.

Deployment

With Pages, deployment is automatic. Commit your changes and push:

1
2
3
git add .
git commit -m "Add comments system"
git push

Cloudflare Pages builds the Hugo site, detects the /functions directory, and deploys both the static site and the serverless function together.

Enabling Comments on Posts

Add comments: true to any post’s front matter:

1
2
3
4
5
---
title: "Your Post Title"
date: 2026-01-01
comments: true
---

The partial checks this parameter and only renders the comments section when enabled.

Performance Considerations

D1 queries run at the edge, close to visitors. For a personal blog, performance is not a concern. For higher traffic sites:

  • D1 handles reads efficiently
  • Writes are more limited but sufficient for comment volumes
  • Turnstile adds minimal latency (invisible mode requires no user interaction)

The free tier includes 100,000 D1 reads per day and 5 million Turnstile verifications per month. More than enough for most sites.

Security Notes

Several design choices prioritize security:

Input sanitization - All user input is escaped before storage and display Turnstile verification - Prevents automated spam submissions Moderation by default - Comments require approval before appearing IP hashing - Like deduplication uses SHA-256 hashed IPs, not raw addresses Same-origin requests - No CORS complexity since the API lives on the same domain

When Things Did Not Work

I followed all the steps above. Deployed. Refreshed the page. And nothing worked.

This is the part of the tutorial where I share what actually happened, because real implementations rarely go smoothly on the first try.

The Database Error

First problem: clicking like or submitting a comment returned {"error":"Database error"}. The D1 binding was configured in the dashboard, but the Pages Function was not picking it up.

The culprit was wrangler.toml. Pages was ignoring it because it lacked a required property. The build logs even said so:

1
A wrangler.toml file was found but it does not appear to be valid.

The fix: add pages_build_output_dir to make Pages recognize the config file:

1
2
3
4
5
6
7
8
name = "uk4in"
compatibility_date = "2024-01-01"
pages_build_output_dir = "public"

[[d1_databases]]
binding = "DB"
database_name = "your-database-name"
database_id = "your-database-id"

After adding that line, Pages read the D1 binding correctly.

The Invisible Widget

Second problem: the Turnstile widget never appeared. No checkbox. No challenge. Just empty space where the widget should be.

I checked the browser console. The Turnstile script was loading, but the callback was never firing. The issue was timing. The callback functions were defined inside an IIFE (immediately invoked function expression), but Turnstile needed them to be global and defined before the script loaded.

The fix: define callbacks on the window object in a separate script block that runs before loading the Turnstile API:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<script>
window.turnstileToken = null;

window.onTurnstileSuccess = function(token) {
  window.turnstileToken = token;
  document.getElementById('submit-comment').disabled = false;
};

window.onTurnstileLoad = function() {
  var container = document.querySelector('.cf-turnstile');
  if (container && window.turnstile) {
    turnstile.render(container, {
      sitekey: container.getAttribute('data-sitekey'),
      callback: window.onTurnstileSuccess
    });
  }
};
</script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad&render=explicit" async defer></script>

The key changes: explicit rendering mode, global callbacks, and proper script ordering.

The Empty Site Key

Third problem: the widget appeared, but showed “Success!” and then immediately failed with “Invalid captcha” when submitting.

I viewed the page source and found the issue:

1
<div class="cf-turnstile" data-sitekey data-callback="onTurnstileSuccess">

The data-sitekey was empty. The Hugo template variable was not rendering.

The cause was TOML structure. I had placed turnstileSiteKey after a subsection like [params.cover], which meant it was being interpreted as part of that subsection rather than the main [params] block.

The fix: move the comments configuration before any subsections in hugo.toml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
[params]
  env = "production"
  description = "Your site description"

  # Comments - place BEFORE any [params.subsection] blocks
  commentsApiUrl = "/comments"
  turnstileSiteKey = "0x4AAAA..."

  [params.social]
    twitter = "yourhandle"

TOML is sensitive to where you place keys relative to section headers.

The Invalid Captcha

Fourth problem: even with the widget working and showing “Success!”, the server returned “Invalid captcha”.

The Turnstile widget was validating correctly on the client side, but server-side verification was failing. I added logging to the Pages Function to see what Cloudflare’s siteverify endpoint was returning:

1
console.log('Turnstile verification result:', JSON.stringify(result));

The logs showed: {"success":false,"error-codes":["invalid-input-secret"]}.

The TURNSTILE_SECRET environment variable was either missing or contained the wrong value. I had accidentally used the Site Key instead of the Secret Key.

The fix: in Pages project settings, ensure TURNSTILE_SECRET contains the Secret Key from Turnstile (not the Site Key). The Secret Key is longer and starts with 0x4AAAAAAC... followed by many more characters.

After updating the environment variable, I triggered a new deployment. Environment variables only take effect on fresh deployments.

Lessons from Debugging

Each of these issues took time to diagnose. The error messages were not always helpful. “Database error” could mean anything. “Invalid captcha” did not explain why.

What helped:

  1. Check build logs - Pages tells you when it ignores your wrangler.toml
  2. View page source - Reveals whether Hugo variables are rendering correctly
  3. Browser console - Shows JavaScript errors and Turnstile loading status
  4. Function logs - Cloudflare Dashboard shows real-time logs from Pages Functions
  5. Add logging - When in doubt, log the actual API responses

The final system works. But getting there required patience and methodical debugging.

What I Learned

Building this system reinforced a few things:

First, Cloudflare’s serverless stack has matured significantly. D1, Pages Functions, and Turnstile integrate smoothly once configured correctly. The developer experience is good, but the configuration has sharp edges.

Second, the Pages Functions approach (code in /functions) is cleaner than deploying a separate Worker. Single repository, single deployment, same-origin API. But wrangler.toml needs that pages_build_output_dir property to be recognized.

Third, SQLite is more than sufficient for this use case. The D1 console works well for manual moderation. No need for a complex database.

Fourth, always check your TOML structure. Key placement relative to section headers matters.

Conclusion

A comments system does not require external services or complex infrastructure. Cloudflare’s free tier provides everything needed: database, serverless functions, and spam protection.

The result is a self-hosted, privacy-respecting comments system that deploys automatically with your static site. No servers to manage. No third-party scripts. Full control over your data.

The code is live on this site. Feel free to test it below.