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