← Back to Writings

Writing friction is the real architecture problem

June 5, 2026

I did not want a CMS.

I did not want a Notion export pipeline either. No database behind my posts. No admin UI. No plugin ecosystem where every problem has one blessed solution and that solution is another dependency, just not another Wordpress.

I wanted to write Markdown, push it, and have the boring parts happen correctly.

As you've probably already guessed, it turns out the boring parts are most of the blog.

Slugs, frontmatter, RSS, OpenGraph images, citations, copy buttons, draft handling, static prerendering, heading anchors, dark mode, reduced motion, analytics that do not immediately turn into surveillance.

All the tiny edges you only notice after publishing enough posts.

I realized I was building a system that should disappear while I write, not a blog engine.

That changed most of the technical decisions that followed.

markdown is the API

Posts live in blog/**/*.md.

text
blog/
  2026/
    05/
      A Personal Perspective on Agentic Engineering.md
      Killing Token Costs.md
      mcp should be dead.md
    06/
      Building an opinionated blog system.md

The filename becomes the slug. The folder gives me a human-readable archive structure. The frontmatter gives the build enough metadata to do everything else.

---
title: "Building an opinionated blog system"
date: "2026-06-05"
description: "How this blog turns plain Markdown into a static-first publishing system."
ai_used: true
reading_time: true
---

This is the blog API, for lack of a better term.

It is not a form, a schema registry, or a custom editor. Just a file I can open anywhere, grep through, move around, version with Git, and hand to an agent without explaining some CMS-specific data model first.

The Vite side is equally direct:

const markdownFiles = import.meta.glob("/blog/*/.md", {
  query: "?raw",
  import: "default",
  eager: true,
}) as Record<string, string>;

import.meta.glob pulls all posts into the bundle as raw strings at build time. From there the blog parses frontmatter, renders Markdown, extracts headings, transforms citations, splits footnotes, calculates reading time, and returns a typed Post.

The whole blog index is basically this:

export function getAllPosts(): Post[] {
  return getPosts()
    .filter((post) => !post.draft)
    .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}

Published posts are all posts minus drafts, sorted by date.

There is no "where did that field come from?" problem. I like how little there is to explain.

opinionated means constraints

“Blog system” sounds bigger than it is. The useful part is that it only supports my workflow.

Markdown in Git. Tiny frontmatter. Filename slugs. Dated public URLs. Drafts hidden from indexes but still renderable in development and the rendering customized only where I actually need it.

Everything else is allowed to be weird.

One constraint that keeps biting me: the renderer emits HTML strings, which means React is not in control of those inner elements. If a code block needs a copy button, the renderer has to emit it. If a citation needs a badge, the citation transformer has to emit it. If a callout needs colors, icons, spacing, link styling, and contrast support, that all has to be literal HTML.

With Tailwind v4, class names also need to be discoverable by the scanner, so the renderer keeps class strings as literals.

Ugly? A bit.

But the ugliness lives in the build-time machinery, not in the writing flow. I can live with that. Practical beats elegant here, especially when elegant means “now think about your renderer every time you write a paragraph”.

rendering is where the blog becomes mine

A default Markdown renderer is fine until it starts feeling like rented furniture.

I use marked, marked-highlight, highlight.js, and marked-footnote. Then I override the parts I care about:

const markdownRenderer = new Marked()
  .use(markedHighlight({
    emptyLangClass: "hljs",
    langPrefix: "hljs language-",
    highlight(code, lang) {
      return highlightCode(code, lang);
    },
  }))
  .use(markedFootnote({ refMarkers: true }))
  .use({
    renderer: {
      heading: renderHeading,
      codespan: renderInlineCode,
      code: renderCodeBlock,
      blockquote: renderBlockquote,
      table: renderTable,
    },
  });

Inline code gets the purple pill. Code blocks get syntax highlighting, a language label, an optional filename, and a copy button. Headings get stable IDs and anchor links. Tables get wrapped so they do not break on mobile. Blockquotes become callouts if they start with a GitHub-style marker:

> [!WARNING] GitHub flavored md
> The best kind of markdown

That becomes a warning callout with an icon, colors, spacing, link styling, and contrast support:

[!WARNING] GitHub flavored md The best kind of markdown

The copy button on code blocks is intentionally dumb. The renderer emits it, and the post route attaches one delegated click handler to the article. A click on .copy-btn copies the nearest code element. A citation badge opens the Sources section. A replay button on an animated image reloads it with a cache-busting query string.

Markdown can produce rich HTML, but it does not get to become a second React app in a trenchcoat.

Why no MDX then?

MDX solves a different problem than the one I have.

I do not want React components inside my writing flow. I do not want TSX syntax while thinking about paragraphs. I do not want content and application code sharing the same mental space.

I want to write Markdown and let the rendering system deal with the specifics afterward.

The moment writing starts feeling like frontend development, the system has failed.

headings are navigation too

Long posts need more than a scrollbar.

The blog extracts headings from the raw Markdown, adds the post title as the first entry, and renders a sticky table of contents on larger screens. An IntersectionObserver keeps the active section highlighted.

The annoying detail: headings can contain inline Markdown. The visible heading has to render inline tokens, while the ID needs plain text. Easy to get wrong, fixed once, forgotten forever.

That is the kind of blog-engine problem I actually like: tiny, specific, mildly cursed, and solved in one place.

citations are not footnotes

I write about tools, articles, specs, Hacker News threads, things people said on the internet. I need sources close to the claim, but I do not want the article to read like a law review PDF.

Footnotes are asides. Sources are evidence. Mixing them always felt off.

So the blog supports raw cite tags inside Markdown:

MCP tool descriptions can get expensive fast. <cite>https://github.com/github/github-mcp-server</cite>

During rendering, the citation transformer finds those tags, normalizes URLs, deduplicates them, derives labels from domains, and replaces each tag with an inline source badge. Click the badge and the Sources disclosure at the bottom of the post opens, highlights the relevant source, and scrolls it into view.

A single claim can cite multiple URLs:

This is not just one data point. <cite>https://example.com/a, https://example.com/b</cite>

The badge shows the first source plus a +1 https://example.com/a, https://example.com/b. The Sources section has the full list.

This is one of my favorite parts of the site because it matches how I think about sources. They stay close to the sentence without interrupting it. Visible enough to matter, quiet enough not to turn every paragraph into compliance paperwork.

Footnotes still work for everything else.[^1]

static-first

The site is a Vite + React SPA with TanStack Router. But a blog post should not need JavaScript before it becomes readable.

So the build prerenders static HTML snapshots for every non-draft post, like dist/writings/2026/06/building-an-opinionated-blog-system/index.html.

That matters for crawlers, feed readers, link unfurlers, and AI agents. It also just matters.

A blog post is a document and should be readable as one.

Client JavaScript can add niceties: active table of contents, copy buttons, animated image replay, source highlighting. But the content is already there.

Drafts are split deliberately. getAllPosts() filters them out, so they do not appear in the writings list, RSS feed, llms.txt, generated OG images, or prerender output. getPostBySlug() still returns them, so I can preview a draft locally without building a second workflow.

They stay out of public indexes, but I can still visit them locally.

metadata leaks everywhere

Most of the time, people meet my writings as a link preview, an RSS item, a generated image, or some agent-readable text blob deciding whether the page is worth reading.

So the description goes everywhere: writings list, RSS, meta name="description", OpenGraph, generated preview images, and llms.txt.

That is why descriptions are required for published posts. Technically optional so drafting does not explode. Required by convention because future me should not have to debug a sad empty link preview.

The build also generates SVG OpenGraph images for published posts. Same metadata, many boring outputs. Perfect job for a script.

analytics without the data goblin

I wanted analytics, but not the "I track every click you do" version.

No user profiles. No sessions. No IP storage. No country guessing. No browser fingerprinting. No “anonymous” tracking that is really easy to trace back.

The site tracks pageviews as aggregates.

The stored fields are day, path, referrer host, user agent family, and count.

The referrer becomes just the hostname or direct. The user agent gets normalized into coarse buckets like Chrome, Safari, Firefox, Googlebot, curl, or Other bot. Then the row gets incremented.

Not “Mozilla/5.0 AppleWebKit whatever please reconstruct this person’s life”. Just “Chrome +1”.

That gives me enough signal to answer the only questions I actually care about:

  • What pages are people reading?
  • Are people actually reading my posts at all?
  • Is there a reason why my post about Agentic Engineering got 500 views in one day?
  • Are crawlers hitting the site?

Anything beyond that starts feeling like product analytics. This is a personal blog, not a growth funnel. I'm not selling anything here.

not every problem deserves infrastructure

The writings page has search. It lowercases the query and filters titles and descriptions in memory.

That is it.

There's no index, no API and therefore also no synchronization problems. Just no operational surface area.

A lot of engineering complexity comes from solving problems years too early.

If search ever becomes a real bottleneck, I can earn the right to complicate it. Until then, simple is a feature.

optimize for writing

Most publishing systems optimize for extensibility: themes, plugins, integrations, and structured content models. They are full-fledged applications pretending to be text editors.

Mine optimizes for something smaller. I want it out of the way quickly enough that I can write instead of maintaining infrastructure for writing.

That is the architecture.

[^1]: like this one, which is just me mumbling in the margin