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:
- Add
pages_build_output_dir = "public"to wrangler.toml or Pages ignores your D1 binding - Define Turnstile callbacks on
windowobject before loading the script, userender=explicit - Place
commentsApiUrlandturnstileSiteKeybefore any[params.subsection]blocks in hugo.toml - Use the Turnstile Secret Key (not Site Key) for the
TURNSTILE_SECRETenvironment variable - Redeploy after changing environment variables
Enable on any post:
| |
Moderate via D1 console:
| |
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:
- D1 Database - Stores comments and like counts in SQLite
- Pages Function - Handles API requests at
/comments - 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:
- Go to Cloudflare Dashboard
- Navigate to Workers and Pages, then D1
- Click Create database
- 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:
| |
| |
| |
| |
| |
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:
- Go to Cloudflare Dashboard
- Navigate to Turnstile
- Click Add widget
- Configure the widget:
- Widget name: Something descriptive
- Hostnames: Add your domain (both with and without www)
- Widget mode: Managed (recommended)
- 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:
| |
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:
- Go to your Pages project in the Cloudflare Dashboard
- Navigate to Settings, then Functions
- Scroll to D1 database bindings
- Add a binding:
- Variable name:
DB - D1 database: Select your database
- Variable name:
You also need to add the Turnstile secret:
- In the same Settings area, go to Variables and Secrets
- Add a new variable:
- Name:
TURNSTILE_SECRET - Value: Your Turnstile secret key
- Check Encrypt
- Name:
Hugo Configuration
Add the Turnstile site key to your Hugo config:
| |
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.
| |
When the callback fires, the form becomes interactive.
Moderation Workflow
Comments arrive with approved=0 and do not appear on the site. To moderate:
- Go to D1 in the Cloudflare Dashboard
- Open your database console
- View pending comments:
| |
- Approve legitimate comments:
| |
- Delete spam:
| |
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:
| |
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:
| |
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:
| |
The fix: add pages_build_output_dir to make Pages recognize the config file:
| |
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:
| |
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:
| |
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:
| |
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:
| |
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:
- Check build logs - Pages tells you when it ignores your wrangler.toml
- View page source - Reveals whether Hugo variables are rendering correctly
- Browser console - Shows JavaScript errors and Turnstile loading status
- Function logs - Cloudflare Dashboard shows real-time logs from Pages Functions
- 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.

Comments
Loading comments...