↑ top
← Back to blog
EN | ES

Adding a Blog to a Cloudflare Worker Without a CMS — Cloudflare KV as a Database.

No database, no admin panel, no origin server. Just Markdown files, a publish script, and Cloudflare KV.

✦ AI Summary

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.

Adding a Blog to a Cloudflare Worker Without a CMS — Cloudflare KV as a Database.

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:

  1. Reads all .md files from posts/
  2. Validates that each has title, date, and excerpt in the frontmatter
  3. Skips posts marked draft: true or with a future date
  4. If an image exists in images/<slug>.png, uploads it to R2 and sets image: true in the frontmatter
  5. Writes scripts/kv-seed.json — an array of { key, value } pairs
  6. Runs wrangler kv bulk put to 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-future to 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:

  1. Detects changes in posts/ or images/
  2. Runs npm run publish — uploads images to R2, generates kv-seed.json, seeds KV
  3. Auto-commits kv-seed.json and updated frontmatter back to main

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-run before 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.

Was this helpful?