The MDX-loader bug that shipped for months
How a single-character typo (singular vs plural directory names) silently 404ed every MDX-backed dynamic route on niyra.ai for months — and the small fix that brought it all back.
I noticed last week that `/features/memory` returned 404 in production. Not "I just deployed something that broke" — I mean, it had been 404ing for *months*.
This is the postmortem.
## What the bug was
`web/src/lib/mdx.ts` is the loader for MDX-backed content. It iterates a `ContentType` enum (`"feature"`, `"doc"`, `"persona"`, etc.) and tries to read the matching directory from `web/src/content/`. The relevant line:
```ts
const dirs = [
path.join(CONTENT_DIR, t), // bug: t = "feature"
path.join(CONTENT_DIR, "generated", t),
];
```
`t` was the singular enum key. But the actual content directory was `features/` (plural). So the loader scanned `web/src/content/feature/`, which didn't exist, found 0 pages, and returned silently.
`features/[slug]/page.tsx` called `getAllPages("feature")` and got `[]`. `generateStaticParams` returned no params. Every URL like `/features/memory` had no matching pre-rendered route and fell through to 404.
## Why it shipped
Three reasons stacked:
**The loader never threw.** `walk()` had `if (!fs.existsSync(dir)) return;` — designed to be tolerant of missing directories during early bootstrap. The cost of tolerance was that a real bug masqueraded as "no content yet, that's fine."
**No CI test pinned the contract.** Build succeeded. Lighthouse never ran on the affected URLs. Sitemap generation gracefully degraded. Nothing on the CI side asked "does `getAllPages("feature")` return more than zero rows?"
**The hub pages worked.** `/features` (the hub) was a static page with hand-written content, not driven by the loader. So when you clicked around the site, the hub looked fine. The 404s lived in the dynamic-slug layer, which is hard to notice without explicitly visiting `/features/memory`.
## How it surfaced
I was building a vector-search indexer that walks every MDX file and embeds it into pgvector. The first dry run reported "found 0 pages." I assumed the indexer was broken. After ten minutes of debugging the indexer, I realized the loader had the same bug — and the indexer was just faithfully reproducing it.
`curl https://niyra.ai/features/memory` confirmed: 404. In production.
## The fix
```ts
const DIR_NAME: Record<ContentType, string> = {
feature: "features",
doc: "docs",
channel: "channels",
persona: "personas",
painState: "pain-states",
// ... rest
};
const dirs = [
path.join(CONTENT_DIR, DIR_NAME[t]),
path.join(CONTENT_DIR, "generated", DIR_NAME[t]),
];
```
A one-time mapping from enum key to actual directory name. Same fix mirrored in the indexer script.
After deploy: `/features/memory` returned 200. Same for `/docs/quickstart` and every other MDX-backed page. Twenty or so pages came back online in the time it took Vercel to deploy.
## The honest lesson
This wasn't a hard bug. It was a tolerant-loader-meets-tolerant-CI bug. The right fix going forward isn't "be more careful with directory names." It's:
1. **Hard-fail when a known type has zero pages.** If the loader iterates `ContentType` and finds 0 entries for a type that should have content, throw at build time. The runtime cost of an extra check is nothing compared to silently shipping a 404.
2. **Add a CI test that hits the dynamic routes.** A 3-line test that fetches `/features/memory` and asserts 200 would have caught this in PR.
Neither was hard. Neither was prioritized. Both are now done.
## What it cost us
Hard to say exactly — we don't have rank-tracking on these URLs from before today. Best estimate: a few months of lost discoverability on the feature pages and a meaningful chunk of "Niyra docs" SEO. Search Console will tell us the recovery curve over the next two weeks.
The lesson I'm keeping: tolerant systems are great until tolerance becomes invisibility. When in doubt, fail loud.
engineering postmortembug fixNext.js MDXsilent failures