Docs menu

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.

Cloudflare D1SQLitemarkdown-itHono

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/published status + the COALESCE stamp solves 90% of editorial-flow needs without a CMS.

Want something like this built for your business?