-
-
Notifications
You must be signed in to change notification settings - Fork 3
SSR Support
@highlighters is built to render on a server without crashing, but a highlighter mark is fundamentally a client-side effect: its geometry is measured from the browser's text layout, then painted as an overlay. This page covers what runs server-side, the DOM-free @highlighters/core/path entry, and how each framework binding behaves during SSR and hydration.
The short version:
- The DOM entry points (
highlight,highlightAll,highlightSelection) are SSR-safe: outside a DOM they return an inert no-op handle instead of throwing. - Marks are drawn on the client, after the browser has laid out the text. There is no server-rendered ink.
- Server-render the text and its markup (including
data-highlightandsemantic<mark>wrappers); attach marks on mount. -
@highlighters/core/pathis a DOM-free subset for config, palettes, RNG, and pure geometry. It is for advanced offline pipelines, not for pre-rendering a mark from a width and height.
Unlike a static clip-path that can be computed from a known width and height, a highlighter mark is anchored to the per-visual-line rectangles of real, laid-out text. The pipeline is: target -> DOM Ranges -> per-line rects -> absolute-px geometry -> renderer. The first three steps need a browser that has measured the text (wrapping, web-font metrics, reflow). None of that exists on the server.
So the marks are deterministic but client-painted. Determinism still buys you something important: every per-mark random value derives from a seed, never the wall clock, so the same text laid out the same way produces byte-identical geometry on every load. There is no hydration flash from re-randomized ink.
All three DOM entry points guard on the presence of document + window. When that guard fails (Node, an edge runtime, a build step), they return an inert no-op handle rather than throwing:
import { highlight } from "@highlighters/core";
// On the server this returns an inert handle. No throw, no DOM access.
const handle = highlight("#intro");
handle.show(); // no-op
handle.isShowing(); // false
handle.tier; // "css"
handle.remove(); // no-opThe inert handle satisfies the full MarkHandle interface, so callers never have to branch on the environment. highlightAll() and highlightSelection() behave the same way: inert outside a DOM. highlightSelection() additionally returns an inert handle on coarse (touch) pointers, deferring to native selection UI.
Because the handle is inert and not deferred, you can safely call an entry point in code that runs on both server and client. Just remember the mark only actually appears once the same code runs in the browser.
See API-Reference for the full handle contract.
@highlighters/core/path is a subpath export that never touches window, document, Element, or Range, not even at module load. It runs in Node, edge runtimes, Deno, Bun, and Web Workers.
import {
resolveOptions,
mergeOptions,
DEFAULT_OPTIONS,
PALETTES,
getPalette,
resolveSwatch,
defaultSwatch,
buildMarkGeometry,
buildClipPath,
buildEdge,
buildNoiseTile,
buildNoiseTileDataUrl,
buildPoolGradient,
hashJitter,
hashU32,
mulberry,
} from "@highlighters/core/path";| Group | Exports |
|---|---|
| Config helpers |
resolveOptions, mergeOptions, DEFAULT_OPTIONS
|
| Palette helpers |
PALETTES, getPalette, resolveSwatch, defaultSwatch
|
| Pure geometry builders |
buildMarkGeometry, buildClipPath, buildEdge, buildNoiseTile, buildNoiseTileDataUrl, buildPoolGradient
|
| Seeded RNG |
hashJitter, hashU32, mulberry
|
| Pure types | every config/resolved type, the enums, and the pure data types (Box, LineRect, Anchor, EdgeVertex, NoiseTile, MaskOffset, PoolGradient, MarkGeometry) |
These are all pure. resolveOptions and mergeOptions do no DOM access; resolveOptions clamps and fully defaults a partial into a ResolvedOptions.
import { resolveOptions, defaultSwatch } from "@highlighters/core/path";
// Validate / normalize options anywhere - build steps, edge config, tests.
const resolved = resolveOptions({ opacity: 2, palette: "fluorescent" });
resolved.opacity; // 1 (clamped)
resolved.color; // "#fff14d" (fluorescent default swatch)
defaultSwatch("vintage"); // "#e3c567"The /path entry deliberately omits everything that touches the DOM. None of these are importable from @highlighters/core/path:
| Excluded | Why |
|---|---|
highlight, highlightAll, highlightSelection, group
|
Render entry points; mount overlays into the DOM. |
toRanges, rangesToLineRects, computeAnchor, collectPageRanges, findTextRanges, snapRangeToBounds
|
Targeting; build and measure Ranges. |
createReflowObserver, createMutationWatcher
|
Observers; need window. |
detectEnvironment, selectTier
|
Tier selection; feature-detects DOM/CSS APIs. (detectEnvironment is itself SSR-safe but lives only in the full core entry.) |
| DOM-touching types |
Target, TextTarget, PageTarget, MarkHandle, GroupHandle, Renderer, RenderContext, RenderEnvironment, the callback aliases, and the speed/ResolvedSpeedDynamics types. |
All of the above live in the full @highlighters/core entry. Use that one in the browser; use /path where there is no DOM.
This is the important caveat. buildMarkGeometry does not take a (width, height). It takes a LineRect (an absolute-px rectangle for one visual line, plus a stable seed and first/last flags), a ResolvedOptions, and a seed:
function buildMarkGeometry(
lineRect: LineRect,
options: ResolvedOptions,
seed: number,
flowReversed?: boolean,
speedProfile?: LineSpeedProfile,
): MarkGeometryThose LineRects come from measuring laid-out text in a browser (rangesToLineRects, which is DOM-only and not in /path). So /path lets you run the geometry math anywhere, but you must supply the rects yourself. There is no path-only way to derive them from a string of text, because text wrapping and font metrics are a browser concern.
In practice this means /path is for offline or edge pipelines where you already have rectangles (a tool that re-paints captured layout, a test that feeds synthetic rects, a worker that batches geometry off the main thread), not for pre-rendering a mark in a Server Component. For ordinary apps you will not call the geometry builders directly; the browser entry points do it for you.
import { buildMarkGeometry, resolveOptions } from "@highlighters/core/path";
import type { LineRect } from "@highlighters/core/path";
// You provide the rect (e.g. from a prior client-side measurement you persisted).
const line: LineRect = {
left: 24, top: 100, width: 320, height: 24,
seed: 42, isFirst: true, isLast: true,
};
const geometry = buildMarkGeometry(line, resolveOptions({ markType: "highlight" }), 42);
// Deterministic: same inputs -> byte-identical MarkGeometry, server or client.See How-It-Works for the full geometry pipeline and API-Reference for the builder and type signatures.
For a server-rendered app, render the content on the server and attach the mark on the client. The text never changes, so there is no layout shift on hydration; the ink simply draws on once the client takes over.
Two pieces of state are worth rendering on the server so the client has something to act on:
-
data-highlightattributes, if you usehighlightAll(). The page scan and watcher pick these up on the client; rendering them server-side means they are present the instanthighlightAll()runs. -
semanticmarks. With{ semantic: true }, each run is wrapped in a real<mark>on the client. The<mark>is added at mark time and removed byremove(). It is not produced on the server, so do not rely on server HTML containing it; if you need<mark>in the server output for SEO or no-JS readers, author it yourself.
All three framework packages render their text server-side and only attach marks after mount. The bindings re-export the core types, so you can import HighlightOptions, MarkHandle, and friends from whichever package you depend on.
The React build ships a "use client" banner on every output file. The Highlight component and the useHighlight hook are therefore client components: they will not execute during SSR, and Next.js treats them accordingly.
The hook also picks the right effect for the environment: useLayoutEffect on the client, useEffect during SSR, so React does not warn about layout effects on the server.
A Server Component renders the text; drop a client Highlight (or a client component using the hook) where you want the mark:
// app/page.tsx - a Server Component, no "use client" needed.
import { Highlight } from "@highlighters/react"; // client component (banner)
export default function Page() {
return (
<p>
The part that matters most is{" "}
<Highlight options={{ color: { palette: "fluorescent", swatch: "yellow" } }}>
this exact sentence
</Highlight>
.
</p>
);
}The <span> (or your as tag) and its text are server-rendered; the ink draws on after hydration. For the hook, the marked element renders server-side and the mark attaches on mount:
"use client";
import { useRef } from "react";
import { useHighlight } from "@highlighters/react";
export function Lede() {
const ref = useRef<HTMLParagraphElement>(null);
useHighlight(ref, { color: "#aacfe0", opacity: 0.6 });
return <p ref={ref}>Server-rendered text, client-attached mark.</p>;
}If you want geometry math in a Server Component or route handler, import from @highlighters/core/path (not @highlighters/react), and remember you must supply the rects.
The useHighlight composable sets up on onMounted and cleans up on onBeforeUnmount, so it is inert during SSR and only attaches in the browser. The Highlight component renders its default slot server-side and marks it on mount.
In Nuxt the component renders fine on the server (it is just a <span> with your text), and the mark attaches after hydration. If you prefer to skip server rendering of the wrapper entirely, wrap it in <ClientOnly>:
<template>
<ClientOnly>
<Highlight :options="{ color: { palette: 'mild', swatch: 'pink' } }">
this exact sentence
</Highlight>
</ClientOnly>
</template>The composable works the same way: the ref element is server-rendered, and the mark attaches on mount.
<script setup>
import { ref } from "vue";
import { useHighlight } from "@highlighters/vue";
const el = ref(null);
useHighlight(el, { color: "#bfe0b2", opacity: 0.6 });
</script>
<template>
<p ref="el">Server-rendered text, client-attached mark.</p>
</template>For server-side geometry in a Nuxt server route or useAsyncData, import from @highlighters/core/path:
// server/api/resolve.ts
import { resolveOptions } from "@highlighters/core/path";
export default defineEventHandler(() => resolveOptions({ palette: "calm" }));The Svelte use: action runs only after the element is mounted in the browser, so it is inherently client-only. The element and its text render server-side; the action attaches the mark on mount and destroy() removes it.
<script>
import { highlight } from "@highlighters/svelte";
const options = { color: { palette: "calm", swatch: "sky" }, edge: { waviness: 0 } };
</script>
<p use:highlight={options}>Server-rendered text, client-attached mark.</p>For pure config or geometry in a SvelteKit load function, import from @highlighters/core/path:
// src/routes/+page.server.ts
import { resolveOptions } from "@highlighters/core/path";
export function load() {
return { options: resolveOptions({ markType: "underline", palette: "vintage" }) };
}- No re-randomization on hydrate. Geometry is seeded, not wall-clock random, so the client paints the same mark every time. Identical text plus identical options yields byte-identical geometry.
- No layout shift from the mark. The mark is a non-interactive overlay mounted into the host (the body by default), not an inline box around the text. The text occupies the same space before and after the mark draws on, so attaching a mark on hydration does not reflow content.
- Server HTML is the text, not the ink. Treat the server output as the readable, accessible baseline. The mark is progressive enhancement: with JS disabled or before hydration, the text is fully present and the page reads normally. See Accessibility-and-Reflow.
-
semanticanddata-highlight. Asemantic: true<mark>wrapper is added on the client, not in server HTML.data-highlightattributes you author are rendered server-side and acted on byhighlightAll()on the client.
Some framework JSDoc examples in the wild show preset and bare named-color shorthands (for example { preset: "wet", color: "pink" }). There is no preset option, and a bare string like "pink" is treated as a raw CSS color, not a palette lookup. To pull from a curated family use a palette reference: { color: { palette: "fluorescent", swatch: "pink" } }, or { palette: "mild" } for that family's default swatch. See Color-and-Palettes.
- Which-API-Should-I-Use: choosing between the entry points and bindings
- How-It-Works: the target -> rect -> geometry -> renderer pipeline
- Performance: tier selection and the auto-degrade thresholds
- Accessibility-and-Reflow: the text-untouched, overlay-only contract
- API-Reference: full function and type signatures
- Limitations: where the library does not apply
Getting Started
The Mark
- Mark-Types-and-Shapes
- Tips-and-Edges
- Ink-and-Optics
- Color-and-Palettes
- Snapping-and-Overshoot
- Animation
Targeting
Reference
Production