SSR and hydration
Server rendering on motif works through a small surface: a per-request collector that captures the CSS the render emits, a context that routes components to it, and a flush hook that inlines the collected rules into the document head. The client takes over from there.
What needs to land before hydration
A motif page hydrates correctly when three things are present in the streamed HTML:
- The class names motif assigned during render. They land on the JSX naturally — no special wiring.
- The CSS rules those classes point at. This is the part the server needs to capture.
- The token block (
<style>containing the CSS custom properties for every theme). Emitted by<ThemeProvider>during render; lands automatically.
Without the rules, the first paint is unstyled. The class names are on the DOM but the browser has nothing to apply against them — a flash of unstyled content that disappears as soon as React mounts and the runtime path takes over. The collector exists to make the second item arrive before paint.
SSRStyleCollector captures everything
Each server request creates a SSRStyleCollector. It is a small object with a single job: hold
every CSS rule motif's runtime emits during the render, in insertion order, deduplicated by
hash.
Two things to notice:
- The collector is per-request. Sharing one across requests would mix two users' style cascades into the same blob. Always construct a fresh collector on every render.
- The context wraps the entire tree. Anywhere a motif primitive runs underneath that provider, the rules it emits route into this collector instead of the default storage.
Once the render is done, collector.getCss() returns the captured rules as one CSS string,
ready to inline.
Streaming frameworks call drain often
Synchronous renderers (renderToString) capture everything in one go. Streaming renderers
(Next.js App Router, React's renderToReadableStream) flush HTML in chunks and need the
collector drained between chunks — the rules for the first chunk have to land in the first
flush, not the last.
Frameworks expose a hook for that. In Next.js App Router, the hook is useServerInsertedHTML:
_drain() empties the collector after the rules are inlined. Subsequent components that emit
the same rule do not produce duplicates; they have already landed in an earlier flush.
This registry component is the standard shape. Copy it into a Next.js project and the rest of motif works the same as in any client-only setup.
Hydration is a handoff, not a re-emission
On the client, motif's runtime path runs after React hydrates. The first thing it does is read
the existing <style data-motif-ssr> block (and the related <style data-motif-style-cache> block, if one was emitted by an earlier session) to find out which
rules are already on the page.
Any rule that is present is marked as already-emitted in the runtime cache. The runtime never re-injects it. New rules — the ones that come from interactions, route transitions, or components that did not render on the server — go into a separate runtime cache and inject through the regular path.
The handoff is invisible. From the user's perspective, the page paints once with the correct styling and stays that way through hydration.
flushPendingCss and the lower-level API
SSRStyleCollector.getCss() is the high-level path. A lower-level API is exposed for
frameworks that need to capture or inject motif's pending rules outside the React render — for
example, a Vite dev-server middleware that wants to splice the collected CSS into the document
without going through useServerInsertedHTML.
flushPendingCss()— drains motif's default runtime cache and returns the pending rules. Used when you are emitting from outside React.injectAtRules(rules)— applies a set of@media/@containerrules into the runtime cache directly. Used when restoring from a captured server snapshot.injectPseudoRules(rules)— same, for:hover/:focusand friends.
These three are advanced. The collector pattern covers the common case; reach for the lower-level API only when you are wiring a renderer the standard pattern does not fit.
Native has no equivalent
SSRStyleCollector and friends only exist in the web build. On native, there is no document to
stream into and no CSS cascade to seed — the runtime resolver evaluates style bags directly and
produces React Native style objects at render time. The seam between server and client
disappears.
Cross-platform code that imports SSRStyleCollector will fail to resolve on the native side.
Keep the import inside files the native bundler does not pull in (e.g. app/layout.tsx for
Next.js projects, which only ever runs on web).