Skip to content

SSR Support

Jace edited this page Jun 6, 2026 · 1 revision

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-highlight and semantic <mark> wrappers); attach marks on mount.
  • @highlighters/core/path is 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.

Why there is no server-rendered mark

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.


SSR-safe by default: the inert handle

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

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


The DOM-free entry: @highlighters/core/path

@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";

What is included

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"

What is excluded

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.

The geometry builders need rects you do not have on the server

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,
): MarkGeometry

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


The realistic SSR pattern

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-highlight attributes, if you use highlightAll(). The page scan and watcher pick these up on the client; rendering them server-side means they are present the instant highlightAll() runs.
  • semantic marks. With { semantic: true }, each run is wrapped in a real <mark> on the client. The <mark> is added at mark time and removed by remove(). 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.

Framework bindings

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.

React / Next.js

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.

Vue / Nuxt

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" }));

Svelte / SvelteKit

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" }) };
}

Hydration notes

  • 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.
  • semantic and data-highlight. A semantic: true <mark> wrapper is added on the client, not in server HTML. data-highlight attributes you author are rendered server-side and acted on by highlightAll() on the client.

Note on the color option

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.


See also

Clone this wiki locally