Your posts have code blocks and you want them coloured. Every tutorial reaches for the same thing:
"use client";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
export function Code({ children, language }: { children: string; language: string }) {
return <SyntaxHighlighter language={language} style={vscDarkPlus}>{children}</SyntaxHighlighter>;
}
It looks right, and it is expensive in three ways at once.
It ships a parser to the browser. react-syntax-highlighter with the Prism
build is roughly 200-300kb of JavaScript before your theme, and the "load only
the languages you need" variants still pull in the core tokenizer. Your reader
downloads a syntax parser to look at a nine-line snippet that was already
finished when you committed it.
It is a client component. Everything inside it (the whole code block, and often the post section around it) opts out of server rendering. A prerendered static post page now hydrates.
It flashes. The highlighted markup only exists after hydration, so readers on a slow connection see unstyled monospace, then a repaint.
The work is deterministic and the input never changes after build. It belongs in the build.
The fix: highlight in the rehype pipeline
rehype-pretty-code runs Shiki over your code blocks while MDX is being
compiled. The output is HTML with inline colours or CSS variables already
applied. Nothing about highlighting reaches the browser.
bun add rehype-pretty-code shiki
// next.config.ts
const withMDX = createMDX({
options: {
remarkPlugins: [["remark-frontmatter", { type: "yaml", marker: "-" }], "remark-gfm"],
rehypePlugins: [
"rehype-slug",
[
"rehype-pretty-code",
{
// Two themes, switched with CSS, so dark mode costs nothing extra.
theme: { light: "github-light", dark: "github-dark" },
keepBackground: false,
defaultLang: "text",
},
],
],
},
});
Remember the Turbopack constraint: plugins are named as strings and their
options must be JSON-serialisable. [rehypePrettyCode, { theme: myThemeObject }]
(an imported function and a JavaScript object) fails at config validation. If
you need a custom theme, reference it by name after registering it, or use one
of the bundled themes.
keepBackground: false tells the plugin to leave the background to your own
CSS, which is what you want in a token-driven design: the code block should use
the same surface colour as the rest of the page.
Wire it into your styles
With keepBackground: false, style the block from your existing tokens:
/* globals.css */
pre[data-theme] code {
display: grid; /* one row per line, so line numbers and highlights work */
font-size: 0.875rem;
}
[data-highlighted-line] {
background-color: color-mix(in oklab, currentColor 8%, transparent);
}
Because Shiki emits per-token colours, dark mode is a CSS switch rather than a second highlight pass. With the dual-theme option above, both sets of colours are in the HTML and CSS picks one.
What this costs and what it buys
The trade-off is real, so know it before you commit:
- Build time goes up. Shiki loads a grammar per language and a theme; the first build after a cold install is noticeably slower. On a blog with a hundred posts this is seconds, not minutes, and it happens once per deploy instead of once per reader.
- The HTML gets bigger. Per-token
<span>s with colours add maybe 20-40% to the size of a code-heavy page's HTML. That HTML compresses extremely well (it is highly repetitive) and it is streamed as part of the document instead of being a separate blocking request. - Client JavaScript goes to zero for highlighting. That is the whole point.
The even cheaper option
If your posts are mostly configuration and shell snippets, consider not
highlighting at all. A code block that uses your design tokens (a
bg-surface-strong panel, monospace, generous padding, horizontal scroll) is
readable, matches the site, and costs nothing:
pre: (props) => (
<pre className="bg-surface-strong text-body-strong my-lg overflow-x-auto rounded-md p-md" {...props} />
),
Plenty of well-regarded engineering blogs ship exactly this. Highlighting is worth adding when your readers are scanning long snippets in a language with meaningful keyword density, and it is decoration when they are reading four lines of YAML.
Confirming it worked
bun run build && bun run start
Then, on a post with a code block:
- View source, not the inspector, the raw HTML. The colour spans must be in the document as it arrives. If they only appear in the inspector, you are still highlighting on the client.
- Disable JavaScript and reload. The code block should look identical.
- Check the network panel. No highlighting library should be requested.
- Compare page weight to a post with no code blocks. The difference should be HTML, not JavaScript.
If you see a flash of unstyled code, something in the chain is still a client component, usually a wrapper someone added for a copy-to-clipboard button. That button is fine as its own tiny client component; it does not need to own the code block to sit on top of it.