What I didn't need yet

Jarniel Cataluna ·

I planned a CMS with an agent pipeline to run this blog, and stopped. What replaced it is one API route and a Map — and one place I paid for complexity anyway.

Flat illustration on cream. A dense tangle of black, emerald and pink boxes, diamonds and circles joined by right-angled arrows, clustered in the left half of the frame. Separated by empty space on the right, a single small black box with one short arrow into it.

What I nearly built

The plan was a CMS with MCP capability, so an agent harness could be hooked to it and the blog would run itself. Not a writing assistant. A pipeline — content writer, copywriter, validator, graphic designer, researcher. I would share the initial idea of what to write, and everything after that would be automatic.

I stopped because I was over-architecting.

That took longer to see than it should have, and it wasn't a technical realisation. Nothing about it was infeasible. I know how to build each piece.

It was that I had never checked the plan against what it was for. This is a personal site. I write when I have something to say, which is not often, and the pipeline was sized for somebody who writes constantly and isn't me.

So here is what I built instead, which is almost nothing, and the one place I couldn't get away with almost nothing.

No data layer

Start with the data layer, because there isn't one.

No database. No CMS. No authentication, which follows — there is nothing to log into.

The record is a TypeScript file I edit by hand. Posts are MDX files in a folder. Both are read at build time and baked into the page, so nothing is fetched while anyone is reading.

That is not restraint. A portfolio's content changes about as often as its author changes job, and a system built for constant change is paid for in maintenance it will never earn back.

What you stop owning is the part worth noticing. No migrations. No backups. No admin interface, which means no admin interface to secure. No staging copy of the data drifting out of step with production. The entire class of problem that starts with who can write to this does not exist, because the answer is: whoever can open a pull request.

Publishing without a CMS

So how does a post get published.

I open a branch, write the file, and merge it. There is no draft flag anywhere in the repository — the unmerged branch is the draft, and merging is the publish act. That is the entire workflow.

Which reads as the absence of a system until you notice what is doing the work. Branches give me drafts. Pull requests give me review. The merge gives me a publish button with an audit trail welded to it. None of that was built here. All of it was already there.

There is a more uncomfortable version of this, and I did not see it until I was most of the way through writing this post.

I said I didn't build the content pipeline. That is not quite true.

The writer, the editor, the validator, the image generation — they exist. They are skills and scripts in this repository, and every one of them was used to produce the post you are reading. What I didn't build is the CMS they were supposed to live inside, and the MCP layer that was supposed to connect them.

The pipeline was never the expensive part. The platform was.

The one piece of backend

The exception is the contact form, and it is the only server code on this site.

A form has to post somewhere. The alternative is a mailto: link, which opens a mail client the reader may not have configured, on a device that may not have one, and drops the message on the floor when it fails. So: one route, and nothing else.

It is a hundred and twenty-four lines, and most of them are refusing things.

Three of them are the only piece of security work on this site I would call interesting.

function oneLine(value: string, max: number) {
  return value.replace(/\s+/g, " ").trim().slice(0, max);
}

The name and the email get interpolated into the subject line of an email. A newline in either one is a second header. Collapsing all whitespace to single spaces removes it, and the truncation is the same call.

No library. No sanitiser dependency. What makes it survive review isn't the code — it's the comment sitting above it naming what it's for: these values land in a mail subject. Three lines with a reason attached outlast three hundred without one.

The limiter, and its named limit

The other interesting part is the rate limiter, and it is the one I would defend hardest, because it is the one that looks laziest.

const recent = new Map<string, number[]>();
const WINDOW_MS = 60 * 60 * 1000;
const MAX_PER_WINDOW = 5;

Five messages per address per hour, held in memory. When the instance restarts the memory goes with it. Run a second instance and each keeps its own count, so the real ceiling is five times however many happen to be running.

That is a genuine defect, and it is written down. From the project's own brief:

The in-memory throttle is per instance and does not survive a scale-out; that is a known and accepted limit at this traffic level.

I want to be exact about why that isn't a hand-wave, because it's fine at my scale is the sentence that comes just before most outages.

It is not fine at every scale. It is fine at this one. The difference between those two sentences is whether you know which one you are saying.

The alternative was a shared store — something the instances could count in together. That is not hard. It is one dependency and a handful of lines, and I have built that kind of thing before.

What it costs isn't the code. It's a service to provision, a credential to rotate, a second thing that can be down while my contact form is up, and a bill that arrives every month whether anyone fills the form in or not.

So the Map is not the right answer. It is the cheapest answer that isn't wrong yet, with the condition for it becoming wrong written down beside it.

Nothing has tested it. No burst, no abuse, no moment where the ceiling mattered — so this is a prediction I have not been shown to be right about, and I would rather say that than let the absence of a failure stand in as evidence.

That is the whole method, and it's the only part of this post I'd argue for on a project that isn't mine.

One more thing about that route.

There are five ways it can fail, and every one of them ends in the same place.

If the mail service refuses the message: email me directly instead. If the API key is missing: email me directly and it will reach me. If you've sent several in a short window: wait a little, or email me directly.

The form isn't the point. The conversation is the point, and a form that fails quietly costs me the one thing this entire site exists to produce.

So the route's job is not to succeed. Its job is to never be the reason a message didn't arrive.

That's the only part of this I'd call product work rather than engineering, and it's also the cheapest thing in the file — a handful of strings, written once, by somebody thinking about what happens to a person on the other end of a 502.

Where complexity won

I have made this sound like a rule, and it isn't one. There is a place on this site where I did the expensive thing deliberately.

A post's metadata — title, date, summary — is written once, in the frontmatter of the file itself. The index the site reads from is generated out of that at build time rather than kept alongside it.

The cheap version is a hand-maintained list. It works, it costs nothing, and it is wrong the first time I change a title and forget the second place it lives.

Here is what the expensive version cost, from the decision record:

The cost is a build step and a dependency: content-collections brings roughly 150 packages into the tree, and with them a transitive uuid advisory reachable only at build time.

A hundred and fifty packages and a security advisory, in a repository that already pins two dependencies forward because of open advisories elsewhere. I took it anyway, and I wrote down why: nothing else offers typed frontmatter at build time, and making drift impossible was judged better than making it unlikely.

Which is the same calculation as the rate limiter, run in the opposite direction.

The limiter stays cheap because the failure it prevents is one I would notice immediately — the form stops working, someone tells me, or I see it myself. The generated index is worth its bill because the failure it prevents is one nobody would ever report: a stale title on the page, correct in the file, with nothing anywhere disagreeing loudly enough to catch it.

So cost isn't the question. The question is what the failure costs you when nobody is looking.

When I'd actually build it

So when does the CMS get built.

The honest answer is that there is no threshold, and I went looking for one while writing this. Nothing about this site is going to start demanding it. If I write more often it still won't — files in a folder scale considerably further than I do.

I want to build it because I want to build it.

That is a real reason, and it is not the same reason as needing it. The distance between those two is most of this post.

Which reframes what I was doing when I stopped. It wasn't over-engineering in the usual sense — I wasn't gold-plating a requirement. There was no requirement. I had something I wanted to build, and somewhere along the way I had quietly promoted it into something the site needed, and by the time I noticed, the two had become the same sentence in my head.

Stopping wasn't discipline. It was catching the substitution.

I will probably still build it. The difference is that it'll be a project rather than a prerequisite, and nothing will be waiting on it — because nothing ever was.

← All posts