The first MDX tutorial you read tells you how to render one file. The second one tells you how to render a folder. By the third post on your site, you’ve started noticing that the rendering bit was the easy part. There’s a layer of complexity the tutorials don’t cover, because the tutorials never have three posts to think about.
I noticed it the day my homepage stopped linking correctly to my own
tag pages. The /writing page’s tag filter built URLs like
/writing/tag/build log/, with a literal space. The static
export only knew about /writing/tag/build-log/. Every multi-word
tag link on the site was dead, and had been since the day I shipped
the tag system.
Reading back through docs/devlog.md, I noticed the same shape of
bug surfacing in three different blocks of the production audit
across two days. The pattern was bigger than one bug. An MDX
content pipeline grows three kinds of complexity as it scales, and
the bugs in each layer look superficially similar but have
fundamentally different fixes.
1. Routing complexity
The first wall. Most MDX tutorials show you pages/[slug].tsx and
call it a day. In practice, every additional pivot you add to the
content (tags, dates, featured-state, language, author, draft
status) multiplies the route surface area.
On captainrandom.co.uk the route surface today is:
/writing/(index, sorted featured-first then newest)/writing/[slug]/(one per post)/writing/tag/[tag]/(one per tag, including tags that don’t have any posts yet)
That third route was the source of the multi-word-tag bug. On 2026-05-19 I caught two related defects in the same block of the audit:
2026-05-19 — fix(writing-index): broken tag slugs, broken active-pill class, trailing slashes, soften copy, pin featured. Tag-filter URLs were being built via
${tag.toLowerCase()}, so the display tag"Build Log"produced/writing/tag/build log/with a literal space, while the static export generates/writing/tag/build-log/(hyphenated). Every multi-word tag link on the index was dead.
The fix wasn’t one line; it was the existence of a
slugifyTag() helper that the whole codebase agreed on. Once one
file does its own slugification, you have a routing complexity
problem. Two files doing slightly different slugification is a bug
waiting to ship.
The high-leverage fix at this layer: a single tag-slug function in
src/lib/mdx.ts that every route, every chrome surface, and every
component imports. Lowercase, non-alphanumerics to hyphens, trim. Five
lines of code that prevent the same bug from existing in fifty
places.
The deeper insight: anticipated slugs. The Footer + Features
sections on this site link to tag pages like /writing/tag/retail/
that don’t have any posts yet. A naïve generateStaticParams
that only returns tags-with-posts would 404 those links. The fix is
to return the union of (tags-actually-in-posts) and a hand-curated
list of anticipated slugs, and have the route render a graceful
empty state for the latter:
export async function generateStaticParams() {
const existing = getAllTagSlugs()
return Array.from(new Set([...existing, ...ANTICIPATED_TAG_SLUGS]))
.map((tag) => ({ tag }))
}
That single pattern unblocked four downstream features (Footer links, Features cards, Workshop tag links, post-cover tag chips) without any of them needing to coordinate.
2. Authoring complexity
The second wall. With three posts, you can keep their relative importance in your head. With ten, you can’t. With thirty, you need a system.
Three sub-problems hide inside “authoring complexity,” and they look similar but want different solutions:
Featured-post ordering. Pure newest-first is wrong, because
you have a flagship post that should sit at position one
regardless of date. Pure manual ordering is wrong, because you
don’t want to re-rank every post every time you publish. The middle path is a
boolean featured field in the MDX frontmatter, and a two-pass
sort: featured-first (newest-first within), then non-featured
(newest-first within). Six lines of code in the index page.
Tag taxonomy. Frontmatter accepts strings; strings are typos
waiting to happen. By post ten you’ll have AI Tools in some
files and AI tools in others and ai-tools in one rogue file
that’s now invisible to the tag index. The fix isn’t runtime validation. It’s a
display-label override table in src/data/tags.ts that maps
every slug to a canonical display form. Slugify on the way in (write the post however
you want); resolve to canonical labels on the way out (chrome
always reads from the table).
Per-tag empty-state pages. A reader who clicks a Footer link to
/writing/tag/retail/ shouldn’t see a 404 just because no
post is tagged Retail yet. The empty state needs its own
component: a single “no posts under this tag yet, here’s
the index” surface that the dynamic route renders when
getPostsByTagSlug(slug) returns []. This is the same fix as
the anticipated-slugs pattern from layer one; they reinforce each
other.
The high-leverage move at this layer: treat frontmatter as user-input you don’t fully trust. Validate slugs on read, canonicalise labels on render, and never let the absence of content become a 404.
3. Citation complexity
The third wall, and the one that doesn’t exist for most blogs. Only for ones where the articles reference each other or reference an external source-of-truth like a build log.
By the time captainrandom.co.uk had published two long-form
articles, both of which cited specific dated entries in
docs/devlog.md, I had a question I couldn’t easily answer:
“Which entries does each article cite? Which articles
already cover X? What’s an honest candidate for the next
post?” Skimming the article markdown for 2026-05- references
worked for two articles. It wouldn’t work for twenty.
The solution shipped as the DVLAW
skill
(DevLog Article Workflow). The relevant piece for this article is
the citation graph: a SQLite table that records, per published
article, which devlog entries it backlinks to. Population happens
automatically on a /dvlaw_ship. The Python parses the
article’s MDX, extracts every docs/devlog.md reference
containing an ISO date, joins those to entries by date, and inserts
into article_citations.
The result is a query I run before every new article’s thesis-clearing pass:
SELECT t.tag, COUNT(DISTINCT e.id) AS uncited
FROM entry_tags t JOIN entries e ON e.id = t.entry_id
LEFT JOIN article_citations ac ON ac.entry_id = e.id
WHERE ac.article_id IS NULL
GROUP BY t.tag
ORDER BY uncited DESC;
That’s /dvlaw_inventory. It tells me which devlog tags have
the most entries that no published article has cited: the fertile
ground for the next post. Without it, I’d be
re-litigating decisions about which topics to write about. With it,
the corpus tells me.
The high-leverage move at this layer: bidirectional links between source (devlog) and surface (articles), enforced by tooling rather than by author discipline. A SQLite index is overkill for ten articles. It earns its keep at the point where you can’t remember which post covered which decision.
What this leaves out
This isn’t the complete story of MDX at scale. I’ve deliberately skipped:
- Build performance. Static generation gets slower as posts multiply. There are real fixes (incremental builds, ISR, build-time data fetching). I haven’t hit the wall yet on this site, so I haven’t written the post.
- Custom MDX components. The
mdx-components.tsxmapping that turns<pre>into the macOS-window code-block chrome on this site is its own essay. It belongs in a separate post about design-system integration, not this one about content architecture. - Multi-author / multi-language. Captain Random is single- author and single-language. The complexity story is different again when those constraints lift.
What I’ve described is the common path: a single-author build-in-public site that adds tags, then a featured-post mechanic, then cross-references between articles and a build log. Three walls, in approximately that order.
What this teaches
The walls grow non-linearly. Each adds more complexity than the last, but they appear in roughly fixed order, and each has a single high-leverage fix that prevents a class of bugs from existing in your codebase rather than just fixing the one you noticed.
The mechanical record of building each of these fixes lives in docs/devlog.md: routing complexity in the 2026-05-18 to 2026-05-19 entries, authoring complexity in the same window, citation complexity from 2026-05-21. This essay is the synthesis. The devlog is the audit trail.