architecturetanstack-startcloudflare
Content in git, not in a database
Why this site's content is Markdown files validated at build time, and what that decision costs when you deploy to an edge runtime with no filesystem.
Every personal site faces the same fork. Either it is static, and updating a
typo means a git push and a redeploy, or it has a CMS, and now there is a
database to provision, a vendor to depend on, and a monthly bill to justify.I wanted neither, which forced a question I had not asked before: what am I
actually getting from a database here?The constraint that decided itThis site deploys to Cloudflare Workers. Workers have no filesystem, so
fs.readFile is not an option at runtime, and a database would be the
conventional workaround.The other move is to read the content at build time and ship it inside the
bundle:
const postFiles = import.meta.glob<string>("/content/blog/*.md", {
query: "?raw",
import: "default",
eager: true,
})
Vite resolves that at build time and inlines every file. The result is one code
path that behaves the same on Workers, on Node, and on Vercel — and no
database anywhere in the stack.The cost is real: content only changes when you rebuild. On a site where every
save is a commit, that maps onto how the work already flows.Git already is a content storeOnce content is files, a lot of the justification for a database evaporates.
Things a database would make you build, that git already does:You needDatabaseGitHistoryAn audit table you write yourselfgit logRollbackA soft-delete flag plus a restore flowgit revertReview before publishA draft/publish state machineA pull requestFind every mentionLIKE '%foo%'grep -r foogit diff on a frontmatter change is a better review artefact than watching an
admin form update a row.Validate the frontmatter, do not trust itThe failure mode of file-based content is a typo that renders a broken page. A
missing date or a malformed array should fail the build, loudly, with the path.Every file goes through a Zod schema:const result = schema.safeParse(yaml)
if (!result.success) {
const issues = result.error.issues
.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`)
.join("\n")
throw new Error(`[content] invalid frontmatter in ${source}:\n${issues}`)
}
The output names the file and the field:[content] invalid frontmatter in content/blog/example.md:
- date: expected YYYY-MM-DD
- summary: Required
That error is worth more at build time than a well-rendered page would be at
runtime. The admin panel validates against the same schemas, so a bad write is
rejected before it is ever committed.The part that made me reconsiderA database is a genuinely better fit for one thing here: page views.Posts are low-volume, need review, and benefit from history. Analytics are
high-volume, append-only, and would be absurd as one commit per request. Those
are different shapes of data and they want different stores, so the architecture
is two paths rather than one — and I was wrong to assume they were the same
problem.Worth it?For a site with two authors, one of whom is me: yes. If this had ten
contributors or a real editorial workflow, the calculus would shift quickly and
a CMS would earn its keep.