# 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.

Your posts have code blocks and you want them coloured. Every tutorial reaches
for the same thing:

```tsx
"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.

```bash
bun add rehype-pretty-code shiki
```

```ts
// 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:

```css
/* 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:

```tsx
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

```bash
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
