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.
// 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.
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.
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:
Server and client output match when all of these match:
Themes do not affect highlighted markup.
renderCodeBlockData() and renderCodeFence() trim trailing block whitespace consistently for copy/render workflows. highlight() preserves its input exactly.
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.
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.