The previous post covered hosting a personal site on Cloudflare Workers. Once that was working, the obvious next step was a blog. The question was: how do you add dynamic content — a list of posts, individual post pages — to a Worker that has no origin server and no database?
The answer turned out to be simpler than expected: Cloudflare KV.
Why Not a CMS
The options I considered:
- Ghost / WordPress — requires a VPS, a database, constant updates. Overkill for a personal blog.
- Headless CMS (Contentful, Sanity) — external API dependency, pricing tiers, another account to manage.
- Static site generator (Hugo, Astro) — build step required, more tooling, less control over the output.
What I actually wanted: write Markdown files locally, run one command, and have the posts appear on the site. No admin panel. No external service. No build output to deploy separately.
KV made that possible.
The Storage Model
Cloudflare KV is a key-value store available to Workers at the edge. It's not a relational database — you can't query it. But for a blog, you don't need to query: you need to store a post index and individual post content.
The schema ended up being two types of keys:
index → JSON array of post metadata (title, date, excerpt, slug, image)
post:<slug> → raw Markdown string for each post
Reading the index to render the blog list:
jsconst posts = await env.POSTS_KV.get("index", "json");
Reading a single post:
jsconst content = await env.POSTS_KV.get(`post:${slug}`, "text");
Two KV reads per page load. No connection pooling, no ORM, no query planner. The Worker just reads a value and renders HTML.
The Publish Script Is the CMS
Instead of an admin panel, the "CMS" is a Node.js script that runs locally: scripts/publish.js.
What it does, in order:
- Reads all
.mdfiles fromposts/ - Validates that each has
title,date, andexcerptin the frontmatter - Skips posts marked
draft: trueor with a future date - If an image exists in
images/<slug>.png, uploads it to R2 and setsimage: truein the frontmatter - Writes
scripts/kv-seed.json— an array of{ key, value }pairs - Runs
wrangler kv bulk putto upload everything to KV in one shot
The publish command:
bashnpm run publish
That's it. No login, no API keys in config files, no dashboard. Wrangler uses the same Cloudflare API token already configured for deploys.
Routing in the Worker
The Worker handles three blog-related routes:
js// /blog — list of all posts
if (path === "/blog") {
const posts = await env.POSTS_KV.get("index", "json");
return htmlResponse(renderBlogIndex(posts));
}
// /blog/:slug — individual post
if (path.startsWith("/blog/")) {
const slug = path.slice("/blog/".length);
const posts = await env.POSTS_KV.get("index", "json");
const meta = posts.find(p => p.slug === slug);
if (!meta) return htmlResponse(render404(), 404);
const content = await env.POSTS_KV.get(`post:${slug}`, "text");
return htmlResponse(renderPost({ ...meta, content }, posts));
}
Markdown is converted to HTML server-side using marked:
jsimport { marked } from "marked";
const html = marked.parse(content);
No client-side rendering. The browser receives complete HTML. No JavaScript required to display a post.
Drafts and Scheduled Posts
Two frontmatter fields control visibility:
markdown---
title: "My Post"
date: 2026-06-01
draft: true
---
draft: true— publish skips the post entirely, regardless of date- Future
date— publish skips it until that date arrives; run with--include-futureto override
Both are checked at publish time, not at request time. The Worker never sees unpublished posts because they're simply not in KV.
What the CI Does
Pushing to main with new posts triggers the pipeline automatically:
- Detects changes in
posts/orimages/ - Runs
npm run publish— uploads images to R2, generateskv-seed.json, seeds KV - Auto-commits
kv-seed.jsonand updated frontmatter back tomain
No manual deploy needed after the initial setup. Write the post, create the PR, merge it.
What I'd Do Differently
- Add syntax highlighting earlier. Code-heavy posts look rough without it. A custom server-side tokenizer costs zero extra dependencies.
- Keep the index small. Storing the full Markdown in the index would hit KV's 25 MB value limit fast. Separating metadata from content is the right call from the start.
- Test with
--dry-runbefore the first real publish. The script prints everything it would do without touching Cloudflare. Saved me from publishing half-finished posts.
The Result
A fully functional blog — post list, individual pages, RSS feed, XML sitemap — running entirely on Cloudflare's edge. No origin server, no database, no maintenance. Hosting cost: $0. The only moving part is the publish command.
The tradeoff is that publishing isn't instant from a phone. But for a technical blog where posts are written in a text editor anyway, a local script is the right abstraction.
Next post: automating post publishing from a mobile device with GitHub Actions.