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