Features & Build Notes · · 3 min read
A blog without a CMS: D1 + markdown-it on Cloudflare Workers
Teardown of this site's blog: markdown rendered once at save time (not per request), D1 prepared statements, the COALESCE publish stamp that protects RSS ordering, and draft seeding from markdown files.
This site’s blog uses no CMS: posts live in Cloudflare D1 (SQLite at the edge), are written in markdown through the admin panel, and are rendered to HTML once at save time — not on every request. Here’s the full teardown.
The key decision: render markdown on save, not on read
Admin writes markdown ──▶ render once ──▶ store BOTH (md + html) in D1
Visitor opens a post ──▶ serve the finished html ── zero parsing work per request
The columns sit side by side in the table: content_md (for re-editing) and content_html (for serving). The only trade-off — if the markdown pipeline changes, old posts need re-rendering — is far cheaper than parsing markdown on every page view.
1. The markdown pipeline
// src/lib/markdown.ts
import MarkdownIt from "markdown-it";
import taskLists from "markdown-it-task-lists";
const md = new MarkdownIt({
html: false, // raw HTML is escaped — even admin content is untrusted
breaks: true, // enter = <br>, how non-technical writers think
linkify: true, // bare URLs become links
typographer: true, // straight quotes → curly, -- → em-dash
}).use(taskLists);
export const renderMarkdown = (source: string): string => md.render(source);
html: false is a security decision: even if the admin account leaks, an attacker can’t inject <script> through post content.
2. D1 queries: prepared statements + bindings
// src/db/posts.ts
export async function listPublished(db: D1Database, limit: number): Promise<Post[]> {
const { results } = await db
.prepare(`SELECT ${POST_COLS} FROM posts WHERE status = 'published'
ORDER BY published_at DESC LIMIT ?`)
.bind(limit)
.all<PostRow>();
return results.map(fromRow);
}
Always .bind(?) — never string-interpolate into SQL. D1 is plain SQLite, so all your SQL intuition transfers directly.
3. The small detail that matters: the first-publish stamp
published_at is set once — unpublishing and republishing doesn’t change the publication date (and doesn’t scramble RSS ordering):
export async function setStatus(db: D1Database, id: number, status: string) {
const now = new Date().toISOString();
if (status === "published") {
await db.prepare(
`UPDATE posts SET status = 'published',
published_at = COALESCE(published_at, ?), -- fill only while still NULL
updated_at = ? WHERE id = ?`,
).bind(now, now, id).run();
}
}
4. Seeding an empty database
When the DB is empty, four sample articles are inserted as drafts — a fresh site never looks empty, but nothing goes live without the admin’s approval. The articles are plain markdown files imported as text modules by Wrangler:
// wrangler.jsonc — .md files become importable modules
"rules": [{ "type": "Text", "globs": ["**/*.md"], "fallthrough": true }]
import article from "../../content/seed/what-rag-actually-is.md"; // a string!
Lessons
- SQLite/D1 is more than enough for a blog + solo admin — don’t start with managed Postgres for read-light workloads.
- Store the source (md) and the result (html) side by side; don’t pick one.
- A
draft/publishedstatus + theCOALESCEstamp solves 90% of editorial-flow needs without a CMS.
Want something like this built for your business?