Skip to content
All posts

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
For AI:.md.txt