Why I rewrote my markdown pipeline (again)

Notes from a weekend spent pulling shiki out of the runtime, folding MDX compilation into the build, and learning — again — that the best optimization is the one you stop doing. Here is everything that broke and everything I would keep.

back to journal
next.js

I've rewritten my markdown pipeline three times in the last eighteen months, and each time I told myself it would be the last. This week I did it again — and this time, with some confidence that the choice will hold. These are the notes I took along the way, mostly so I can find them again the next time the temptation strikes.[]

For context: this site is a Next.js App Router project. Posts are MDX files living next to the route segments that render them. Nothing exotic — but the intersection of React Server Components, dynamic imports, and syntax highlighting is a swamp, and I keep stepping into it.

The old setup

Until last weekend I was using next-mdx-remote with a small rehype chain. It worked, mostly. But every time I added a new MDX component I had to thread it through three different boundaries, and the highlighter was bundling a 600KB chunk into every client component that happened to live near a post.

app/journal/[slug]/page.tsx
// the old, painful version
import { MDXRemote } from 'next-mdx-remote/rsc';
import { bundleMDX } from 'mdx-bundler';
import * as runtime from 'react/jsx-runtime';
 
export async function Page({ params }: { params: { slug: string } }) {
  const source = await readPost(params.slug);
  const { code } = await bundleMDX({ source });
  return <MDXRemote source={source} components={mdx} />;
}
On the highlighter

The 600KB chunk came from shiki being imported eagerly. The fix is well-known — lazy it on the server — but I hadn't done it because I'd convinced myself it didn't matter. It did.

What I changed

The new pipeline does three things differently. First, MDX is compiled at build time via a small script, not at request time. Second, the highlighter runs once during that build, produces tokenized HTML, and the runtime never loads shiki at all. Third — and this is the change I'm most pleased about — every MDX component is a regular React component imported from one file. No registry, no map, no boundary gymnastics.

scripts/build-posts.ts
import { compile } from '@mdx-js/mdx';
import { getHighlighter } from 'shiki';
import { rehypeShiki } from './rehype-shiki';
 
const highlighter = await getHighlighter({
  themes: ['github-dark', 'github-light'],
  langs: ['ts', 'tsx', 'bash'],
});
 
export async function buildPost(slug: string) {
  const raw = await readFile(postPath(slug));
  return compile(raw, { rehypePlugins: [rehypeShiki(highlighter)] });
}

The build runs in about 800ms for 40 posts on my laptop. That's fast enough that I run it on every save in dev, gated behind a watcher. The cost of being wrong is the next 30 minutes of my life, and 800ms is well inside the margin where I won't notice.

The best build-time work is the work you can stop doing at runtime without anyone noticing.

something I told myself, in a notebook, in October

A hiccup with shiki

The first attempt blew up because shiki v1.0 changed the import shape — what used to be a default export is now a named one, and the WASM grammar loader needs a base URL when run outside the browser. I lost an embarrassing amount of time to this before noticing the migration guide.

Heads up if you're upgrading

getHighlighter is deprecated in favor of createHighlighter. The old name still works but logs a warning into your build output, which Vercel turns red, which makes you think the deploy failed. Just rename it.

$pnpm build:posts
compiled 40 posts
·shiki warming up……………………………………………… 412ms
·mdx compile ………………………………………………………… 287ms
·write artifacts …………………………………………………… 96ms
done in 795ms
Fig.Build output, after — one slow line is the highlighter warming up.

Caching the highlighter

Booting a fresh highlighter per post is wasteful. The fix is a module-level singleton — but be careful: in Next.js dev mode, the build module is re-evaluated more often than you'd expect, and a true singleton needs to be hung off globalThis if you want it to survive HMR.

Lee Robinson@leeerob

A reminder: in the App Router, “runs once” depends on which module graph you're in. Server components, route handlers, and edge functions all have different lifetimes. If you cache something, pick where it lives on purpose.

What's left to do

Three things I haven't fixed yet and want to come back to:

  • Footnote backlinks render but don't smooth-scroll on Safari [] — I think this is a scroll-padding-top issue but I haven't proved it.
  • Inline <Tweet> embeds are static placeholders. I want them to be real, but not at the cost of loading the Twitter widget on every page.
  • The RSS feed still uses the old pipeline. Migrating that means choosing between rendering MDX twice or shipping HTML I don't fully control. Both options have something wrong with them.

Signing off

I'm writing this in the same editor I built the pipeline in, which feels right — the whole project, including this post, gets a little simpler every time I touch it. That's the goal. If the next rewrite is the last one, it'll be because there's nothing left to take away.

— Kyler

Footnotes

  1. The previous two attempts are written up here and here, if you're curious about the dead ends.

  2. Reproducible on Safari 17.4 but not on Technology Preview, so it may already be fixed upstream.

Subscribe to the journal
New posts on software, craft, and the occasional detour — straight to your inbox. No more than a couple times a month.