Renderer internals
The hybrid MDX pipeline — serialize then run() inside a try/catch — and why a single unsupported feature never 500s a page.
The hybrid MDX pipeline — serialize then run() inside a try/catch — and why a single unsupported feature never 500s a page.
The cardinal rule of the renderer is never let one unsupported feature 500 a page. A docs site is a mix of supported and not-yet-supported MDX; a single unknown component, malformed frontmatter, or compile error on one page must degrade gracefully and never take down the page (let alone the site). Everything below exists to honor that rule.
MDX rendering (src/lib/mdx.tsx) is a deliberate hybrid of two libraries:
serialize brings Shiki dual-theme syntax highlighting and snippet handling, for
high-fidelity highlighting. It produces compiled, serializable output rather than a
rendered element.
The compiled output is executed with @mdx-js/mdx’s run() wrapped in our own
try/catch. Because we run the compiled source, the whole step is catchable: a failure
renders an inline notice instead of throwing.
The obvious simplification — MDXRemote — is wrong here. MDXRemote throws
compile errors at RSC render time, which cannot be caught without an error boundary, and
an error boundary breaks RSC streaming. Running the compiled source ourselves keeps the
entire compile-and-execute step inside a try/catch.
Do not “simplify” the hybrid to MDXRemote. It reintroduces uncatchable RSC-render-time
compile errors. This was measured: MDXRemote rendered a few more pages but produced
HTTP 500s, while the hybrid produced zero 500s on the same sample. Zero 500s is the
whole point. See gotchas.
Compiled MDX references components by name. If a referenced component is not in our set, the
naive behavior is a hard throw — “Expected component X to be defined.” — which 500s the
page. Instead, componentsForCompiled scans the compiled source for every referenced
component and supplies a passthrough Fallback for any that are unknown. An unsupported
component therefore renders its children and a dev warning rather than crashing.
This even covers member-expression components like <Color.Item>: a Proxy stands in
for the namespace object, so Color.Item resolves to the same passthrough fallback. The
result is that an unimplemented component degrades to “render its contents as plain content,”
which is almost always the right thing.
Alongside the component fallback, two more guards keep a bad page from crashing: frontmatter
is parsed defensively (bad YAML does not throw), and a compile failure on the whole page
renders an inline notice instead of a 500. Layered together, these mean any real-world
docs.json repo renders without crashing — supported features render, unsupported ones
degrade.
A subtle constraint ties the two halves together: the development flag and JSX runtime used
to compile (serialize) must match the ones used to run (run()). If they diverge,
React 19 throws “production element rendered in development.” Keeping the runtimes in lockstep
is non-negotiable — it is the reason the renderer cannot fall back to a plain off-the-shelf
remote-MDX setup.
When touching the compile or run side of src/lib/mdx.tsx, keep the development flag and
runtime identical on both sides. A mismatch produces a confusing React 19 error that looks
unrelated to MDX.
The hybrid carries a couple of environment requirements worth knowing:
legacy-peer-deps — the third-party MDX serializer package declares a broken peer dependency
(a nonexistent @radix-ui/react-popover version), so .npmrc sets legacy-peer-deps=true.
Keep it.serverExternalPackages — the MDX packages must be listed in serverExternalPackages
in next.config.mjs, or they fail to compile inside the Next bundle.The performance principle behind all of this: MDX is meant to be compiled once at sync time into serializable bundles, so the render plane only executes them on request — no live compilation on the hot path. The render plane reads compiled content from object storage; see the content pipeline. (Precompiling fully at sync time is the remaining piece; the architecture and the catch-everything pipeline above are built for it.)
Navigating the sidebar re-renders only the article segment — the navbar and sidebar persist. For that to look seamless, the shell has to stay geometrically still while the article underneath it changes size. Three rules keep it there, and dropping any one of them makes the page visibly twitch on every click.
The article row is at least a viewport tall. The sidebar is sticky at a 7rem offset
with its own scroll. A sticky element is clamped by its containing block — here the flex row
pairing the sidebar with the article — so if a short article lets that row collapse, the
sidebar can’t reach its offset and rides up. It would then snap back down as soon as taller
content arrived. Every renderer of that row (the article, auto-generated OpenAPI endpoint
pages, and the loading skeleton) shares one class constant carrying the minimum height, so
they can’t drift apart.
The scrollbar gutter is always reserved. scrollbar-gutter: stable on <html> means the
vertical scrollbar appearing or disappearing never changes the viewport width. Without it, any
page that stops overflowing — a short one, or the brief loading skeleton — widens the viewport
by the scrollbar’s width and slides the centered shell sideways.
Route transitions scroll instantly. The site sets scroll-behavior: smooth so in-page
anchor links glide. Left alone, that also makes the framework animate scroll restoration on
every navigation. A data-scroll-behavior="smooth" attribute on <html> keeps the smooth
glide for anchors while letting route changes land immediately.
Checking this by hand: run papervine dev against any docs folder and click through the
sidebar. A local dev server renders slower than production, so the loading skeleton is on
screen longer and layout instability is easier to spot than on a deployed site.
docs.json.