Skip to content
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

Measure your build before adding a cache. If highlighting takes a material share of build time, cache by package version, language registrations, language, source, decorations, and line-number options.

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

Why no worker?

The synchronous API does not require a worker. For large interactive inputs, measure how long highlighting blocks the UI thread and consider an input limit or a worker. Worker startup and message serialization add costs of their own.