The tools that keep me out of the admin
Back in the opener I wrote "No CMS" and moved on in four words, without saying what happens instead. Then I spent three posts hardening an admin — auth, input validation, a write contract with an audit log — and called the series done. Consider this the postscript, because the honest answer to that "No CMS" is a small toolbox whose only job is keeping me out of the admin I spent all that effort building.
Posts are just files
Every post is a markdown file on my laptop. The filename carries the date and a readable label; the real title lives in a title: line up top, in a frontmatter:
---
title: "Improving the search: highlighted terms"
---
The other day I needed…The title used to be the first line of the file. Then I noticed the filename and the title couldn't always agree: a filename can't hold a colon (macOS quietly rewrites it), or a slash, and Debuggex.com as a filename looks like it grew a second extension. A title like Improving the search: highlighted terms can't round-trip through a filename, so the real one has to live somewhere the punctuation survives, and frontmatter is it.
I didn't pull in a YAML library to read a single field, though. The value is the literal rest of the line:
const titleLine = block.split(/\r?\n/).find((line) => /^title:/.test(line))
const value = unquote(titleLine.replace(/^title:[ \t]*/, "").trim()) // ❶block is whatever sits between the --- fences. We take everything after title: verbatim (❶) and the only trick is stripping a wrapping pair of quotes. A real YAML parser would trip over title: [NJS] Database handling #2: it reads [NJS] as a sequence and #2 as a comment. Those titles only work because I don't parse YAML.
Getting a file into Postgres is one command:
yarn db:import-posts ../blog/posts/tech --overwriteIt reads the folder's markdown files, resolves each slug (the slug: line when present, derived from the title and written back when not), and diffs against the database: unknown slugs are creates, known ones (with --overwrite) are updates, and files that match what's already stored are skipped. --dry-run prints the plan and writes nothing. The import is idempotent: running it twice does nothing the second time.
One rule inside it took a bug to notice. An overwrite refreshes the title, body, date, and reading time. It never touches published:
// `published` is deliberately absent from the update: an overwrite must not
// flip it. Reusing the create rule (past-dated → draft) would unpublish a
// live post the moment I fixed a typo in it.The publish state is left exactly as the overwrite found it.
The script writes to Postgres directly, skipping the admin API, the reverse of the admin's own bulk uploader, which goes through the same write contract as every other write. It gets to skip it because the input is trusted: files I staged myself, validated against the very Zod schema the API enforces, so something the admin couldn't have written won't slip in this way either.
Projects are a manifest
A project has no body to type: it's a screenshot gallery, a pricing table, links, a pile of copy. So it isn't a markdown file; it's a project.json manifest beside its images, and it takes two halves to build.
The editorial half is a Claude Code skill: it reads an app's marketing/ folder, where I keep its landing copy, the store listing, the captioned screenshots, and any other copy; then it maps all of that into the manifest: which sentence becomes the summary, which screenshots become the gallery.
The mechanical half is a script: yarn db:import-projects uploads the images to Vercel Blob and writes the row. I want the two halves to stay separate: the skill decides what the manifest says, the script transforms whatever it says, and neither reaches into the other's work.
The part of that script worth showing is how it survives re-runs. Every image's key in Blob is content-addressed:
key: blobKeyFor(slug, relativePath, contentHashFor(buffer)) // ❶contentHashFor (❶) is the first sixteen hex characters of the SHA-256 of the bytes, baked into the key: projects/reckon/<hash>-screenshot.png. Blob and next/image both cache by URL with no per-URL purge, so overwriting a key keeps serving the stale copy from the edge. Hashing the bytes routes around that: a changed image lands at a URL the CDN has never seen, an unchanged one resolves to its existing key and is reused. Re-import a project after touching one screenshot and exactly one file uploads and the old one is deleted, so it can be pruned.
The admin that's left
So what is the admin for, after all that hardening? Edits, mostly: fixing a typo from my phone, nudging a project's spot in the gallery. What it stopped being is the way things get in. That, and I got to learn how to build one.
There's one pain remaining: because the scripts write straight to Postgres, they can't invalidate Next's cache; that only works from inside the running app. So after a run, I have to click a "Revalidate caches" button in the admin nav, so the caches are rebuilt.
Looking at the finished thing, the write contract mattered more than the admin around it, and it turned out to have three clients: the admin forms, the bulk uploader, and a script that steps around it on purpose because its input is trusted and I prefer running a terminal command to going through the admin page.
As for this post: it's a markdown file with a title: line, and it reached production through yarn db:import-posts, not a textarea. Nobody pressed publish.
Getting a decade of older posts into this shape had its own adventures: a slug bug hiding behind a typographic apostrophe, some URL archaeology, a morning I found my files had drifted ahead of the database and couldn't remember doing it. That might be a different post, for another day.
Maybe your site has grown a sideways pipeline like this too. Maybe I've just described a CMS with extra steps. @roland.leth.ro / @rolandleth.