Part 2: Building a Blog API with Cloudflare Workers

Source code: GitHub

The Limits of a Static Site

After building the blog to a decent state, something felt missing. I could publish posts, but there were no view counts, no idea how many visitors I had, and no way to leave comments. It's expected for a static site, but looking at the blog it just felt... empty.

So I decided to add these three things:

  • Per-post view counts
  • Today / total visitor counter
  • Comments (including threaded replies)

The problem is, you need a server to store data for any of this.


Why Cloudflare Workers

Just because I needed a server didn't mean I wanted to spin up an EC2 instance or rent a VPS. Managing a server just for a blog API felt like the tail wagging the dog.

I compared a few options:

Approach Pros Cons
AWS Lambda + DynamoDB Familiar AWS ecosystem Tedious setup, cold starts
Vercel Serverless Great fit with Next.js Can't use with static export
Supabase PostgreSQL-based, free tier One more external dependency
Cloudflare Workers + KV + D1 Already using Workers, free Need to build it yourself

The deciding factor was that I was already using Cloudflare Workers for CMS authentication. I already had an account, was familiar with the wrangler CLI, and deployment was a single command. Add KV (key-value store) and D1 (SQLite database) and I could build an API without any separate infrastructure.

The free tier is generous:

  • Workers: 100K requests/day
  • KV: 100K reads/day, 1,000 writes
  • D1: 5GB storage, 5M row reads/day

For a personal blog, there's no way I'd hit the paid tier.


Project Structure

I created the Workers project separately from the blog repo. The blog is a static site and Workers are serverless functions, so their lifecycles are different.

minsnote-api/
├── src/
│   └── index.ts        # All API logic
├── schema/
│   └── 001_init.sql    # D1 table creation SQL
├── wrangler.toml       # Workers config
└── tsconfig.json

KV and D1 bindings are configured in wrangler.toml:

name = "minsnote-api"
main = "src/index.ts"

[[kv_namespaces]]
binding = "VIEWS"
id = "..."

[[d1_databases]]
binding = "DB"
database_name = "minsnote-db"
database_id = "..."

Deployment is just wrangler deploy. No need to keep a local machine running -- it runs 24/7 on Cloudflare's edge servers.


Data Storage Design

I split KV and D1 based on purpose.

KV (Key-Value Store) -- View counts, visitor counter, Rate Limit

KV lets you read and write simple key-value pairs quickly. It also supports TTL (expiration time), which is perfect for deduplication checks like "has this IP already visited today."

// Increment view count (same IP only counted once per day)
const dedupeKey = `viewed:${slug}:${ipHash}:${today}`;
const already = await env.VIEWS.get(dedupeKey);
if (!already) {
  await env.VIEWS.put(`views:${slug}`, (current + 1).toString());
  await env.VIEWS.put(dedupeKey, "1", { expirationTtl: 86400 });
}

expirationTtl: 86400 means auto-delete after 24 hours. Duplicate visits reset daily without any cleanup job.

D1 (SQLite) -- Comments

Comments are relational data, so I used D1. Nickname, content, timestamp, password hash, and parent_id for threads.

CREATE TABLE comments (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  post_slug TEXT NOT NULL,
  nickname TEXT NOT NULL,
  content TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  ip_hash TEXT NOT NULL,
  password_hash TEXT NOT NULL DEFAULT '',
  parent_id INTEGER DEFAULT NULL
);

API Endpoints

All APIs are routed by URL pattern from a single Workers file (index.ts).

Method Path Function
POST /api/views Increment view count
GET /api/views?slug=xxx Get view count
POST /api/visitors Record visitor + return count
GET /api/visitors Get today/total count
GET /api/comments?slug=xxx Get comment list
POST /api/comments Create comment
PUT /api/comments Edit comment
DELETE /api/comments Delete comment

I routed without any framework, just if (url.pathname === "/api/views" && request.method === "POST") style. With only 8 APIs, using a framework would be overkill.


CORS Setup

Calling the Workers API from a static site obviously causes CORS issues. We're requesting from https://jinwonmin.github.io to https://minsnote-api.xxx.workers.dev.

function corsHeaders(origin: string, allowed: string) {
  return {
    "Access-Control-Allow-Origin": isAllowedOrigin(origin, allowed) ? origin : "",
    "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type, X-API-Key",
  };
}

I put the production domain in the ALLOWED_ORIGIN environment variable and also allowed localhost:3000 for local development. When I first tested locally, I kept getting CORS errors because Workers' ALLOWED_ORIGIN only allowed the production URL.


Frontend Integration

On the blog side, you just need one API client.

// src/lib/api.ts
const API_BASE = "https://minsnote-api.xxx.workers.dev";

export async function trackView(slug: string): Promise<number> {
  const res = await fetch(`${API_BASE}/api/views`, {
    method: "POST",
    headers: headers(),
    body: JSON.stringify({ slug }),
  });
  const data = await res.json();
  return data.views;
}

Since it's a static site, you can't call external APIs from server components. I made it a "use client" client component and call the API in useEffect on page load.

// ViewCounter.tsx
"use client";
export default function ViewCounter({ slug }: { slug: string }) {
  const [views, setViews] = useState<number | null>(null);
  useEffect(() => {
    trackView(slug).then(setViews).catch(() => {});
  }, [slug]);

  if (views === null) return null;
  return <span>{views.toLocaleString()}</span>;
}

Lessons Learned the Hard Way

KV expirationTtl Minimum Value

I set the comment Rate Limit to 30 seconds and got a 500 error. There was no error message, just error code: 1101. I was stumped for a while until I added try-catch to Workers and finally found the cause.

Invalid expiration_ttl of 30. Expiration TTL must be at least 60.

KV's minimum TTL is 60 seconds. It's probably documented somewhere, but since the error message doesn't surface outside of Workers, it was hard to diagnose. So I wrapped the entire Workers router in try-catch and changed it to respond with error messages in JSON.

Running wrangler deploy from the Wrong Directory

I once ran wrangler deploy from the blog directory by mistake. Wrangler detected the Next.js project and tried to do an OpenNext build, adding all sorts of dependencies to package.json. I reverted with git checkout, but lesson learned: always run wrangler from the Workers project directory.


Today Counter and Timezone

After deploying the visitor counter, I noticed the today count was resetting at a weird time. It wasn't resetting at midnight KST but at 9 AM.

The cause was the todayKey() function:

// Before — UTC-based
function todayKey(): string {
  return new Date().toISOString().slice(0, 10);
}

toISOString() returns UTC time. UTC midnight is 9 AM in Korea. So the today counter was resetting at 9 AM.

I changed it to KST (UTC+9):

// After — KST-based
function todayKey(): string {
  return new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().slice(0, 10);
}

Add 9 hours (in milliseconds) to the UTC timestamp and extract the date. Cloudflare Workers don't have timezone settings, so you could use Intl.DateTimeFormat, but simple addition was the lightest and clearest approach.


Summary

Component Role
Cloudflare Workers API server (serverless)
KV View counts, visitor counter, Rate Limit
D1 Comment storage (SQLite)
wrangler CLI Resource creation, deployment, DB migration

Adding dynamic features to a static site is ultimately a question of "where do you store data?" The Cloudflare Workers + KV + D1 combo lets you build an API for free, without a separate server, in just a few hours -- more than enough for a personal blog.

Comments