Skip to content

Syntax highlighting without a 300kb bundle

Prism and highlight.js in a client component ship a parser to every reader. Highlight at build time with a rehype plugin and ship CSS instead.

MDX blog4 min readships at docs/solutions/blog-mdx/syntax-highlighting-without-a-huge-bundle.md

Tags: mdx · syntax-highlighting · performance · shiki · rehype

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:

  1. 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.
  2. Disable JavaScript and reload. The code block should look identical.
  3. Check the network panel. No highlighting library should be requested.
  4. 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.