Guides

SSR and Client Rendering

SSR and Client Rendering

The recommended architecture is hydrated SSR: highlight the initial document on the server, then reuse the same synchronous registry for client navigations and dynamic content.

Isomorphic module

ts
// src/lib/highlight.ts
import { createHighlighter } from '@tanstack/highlight/core'
import { css } from '@tanstack/highlight/languages/css'
import { html } from '@tanstack/highlight/languages/html'
import { js } from '@tanstack/highlight/languages/js'
import { json } from '@tanstack/highlight/languages/json'
import { shell } from '@tanstack/highlight/languages/shell'
import { ts } from '@tanstack/highlight/languages/ts'
import { tsx } from '@tanstack/highlight/languages/tsx'

export const highlighter = createHighlighter({
  languages: [css, html, js, json, shell, ts, tsx],
})

Do not put server-only imports in this module. The highlighter itself needs none.

Server path

Highlight before serializing or streaming the code block:

ts
const data = highlighter.renderCodeBlockData({
  code,
  lang,
  title,
  decorations,
})

Send or render data.htmlMarkup. Keep data.copyText for a copy button.

Hydration

Server-rendered code does not need to be highlighted again. It is already static HTML controlled by CSS.

Use the client highlighter only for code introduced after hydration:

  • Client-side route content not included in the initial payload
  • Live Markdown previews
  • Interactive examples that change source
  • User-selected language variants

Determinism

Server and client output match when all of these match:

  • Source string
  • Language registrations and order
  • Language option
  • Decorations and line-number option
  • Package version

Themes do not affect highlighted markup.

Avoid hydration mismatches

  • Export one registry module used by both paths.
  • Do not conditionally register languages with window checks.
  • Normalize Markdown fence metadata before rendering on both paths.
  • Do not highlight trimmed code on one path and untrimmed code on the other.

renderCodeBlockData() and renderCodeFence() trim trailing block whitespace consistently for copy/render workflows. highlight() preserves its input exactly.

Caching

The highlighter is fast enough for normal docs pages without a cache. For large static builds, cache by package version, language, source, and decoration options if build time becomes material.

Do not cache theme variants separately. They share the same HTML.

Why no worker?

The docs-sized path is synchronous and usually much cheaper than worker startup and message serialization. A worker is reasonable only for unusually large interactive inputs, which is outside the primary use case.