Hydration & head
After the server sends HTML, the client re-attaches reactivity to that existing DOM. This guide covers the hydration entry point, head management, and the plugin SPI that advanced strategies build on.
The client hydration entry
The high-level entry is the ssrClientPlugin. It adds hydrate() to your App:
import { defineApp } from 'sigx';
import { ssrClientPlugin } from '@sigx/server-renderer/client';
import { App } from './App';
defineApp(<App />)
.use(ssrClientPlugin)
.hydrate('#root');
hydrate() accepts a CSS selector string or an Element. It finds the root
component plus AppContext, then hydrates the existing SSR DOM. If the container
has no SSR content, it falls back to a fresh client render — so the same entry
works for both pre-rendered and client-only pages.
Gate hydration on the completion script
The document renderer emits a trailing script — __SIGX_STREAMING_COMPLETE__ plus
a sigx:ready event — in both 'blocking' and 'stream' modes, so a
blocking-rendered page hydrates the same way a streamed one does. The inline
script runs during HTML parse, before any deferred module script, so your entry
can safely check the flag and otherwise wait for the event before hydrating:
// src/entry-client.tsx
import { defineApp } from 'sigx';
import { ssrClientPlugin } from '@sigx/server-renderer/client';
import { App } from './App';
function start() {
defineApp(<App />).use(ssrClientPlugin).hydrate('#root');
}
if ((window as any).__SIGX_STREAMING_COMPLETE__) {
start(); // document already complete
} else {
window.addEventListener('sigx:ready', start, { once: true });
}
Self-healing on a structural mismatch
Hydration is resilient to SSR/client drift. For a small cursor offset the walk scans forward for the matching node (dev-warns about skipped siblings). For a genuine structural mismatch — the server emitted a different element than the client VNode expects — it self-heals: it creates the expected element fresh, mounts the subtree into it, and advances past the orphaned server node. The page stays correct and reactive instead of duplicating content or leaving a dead subtree. These recoveries warn in dev so you can fix the underlying drift.
Low-level hydration
For custom setups you can call the core hydrate function directly. It normalizes
the element to a VNode, sets the current AppContext for DI, runs client plugin
beforeHydrate hooks, walks the DOM via hydrateNode, then runs afterHydrate:
import { hydrate } from '@sigx/server-renderer/client';
import { App } from './App';
const container = document.getElementById('root')!;
hydrate(<App />, container);
hydrateNode does the per-VNode work: in the happy path it creates no DOM, just
attaches events/props/directives/refs, skips SSR comment markers
(<!--t-->, <!--$c:N-->), recovers from minor SSR drift by scanning forward
(with a dev warning), and returns the next sibling.
Head management
Call useHead() — exported from core sigx — inside any component to manage
<head> elements. During SSR the configs are collected onto the per-request
context; on the client useHead() mutates the DOM directly and registers cleanup
on unmount:
import { component, useHead } from 'sigx';
export const ArticlePage = component((props) => {
useHead({
title: props.title,
titleTemplate: '%s — My Site',
meta: [
{ name: 'description', content: props.summary },
{ property: 'og:title', content: props.title },
],
link: [{ rel: 'canonical', href: props.url }],
htmlAttrs: { lang: 'en' },
});
return () => <article>{props.body}</article>;
});
renderDocument* collects these configs during render and injects the rendered
head HTML before </head> in your template automatically — there is nothing to
wire up. titleTemplate uses %s as the title placeholder, and the renderer
dedupes meta by name/property/http-equiv/charset.
renderDocument injects the collected head into the document for you, so a
useHead() call anywhere in the tree just works — no manual wiring.
Hydrating a server-resolved Defer subtree
The server resolves lazy() components inline (and streams a <Defer>
fallback then swaps in one replacement — see
Rendering & streaming). To hydrate
that server-resolved subtree, the lazy component must be available synchronously
during the hydration walk — otherwise the client would render the fallback again
and mismatch the server output.
Preload the lazy chunk before calling hydrate(). Every lazy() factory exposes
a .preload() promise for exactly this:
// src/entry-client.tsx
import { defineApp, lazy } from 'sigx';
import { ssrClientPlugin } from '@sigx/server-renderer/client';
import { App } from './App';
import { HeavyChart } from './HeavyChart'; // a lazy() component used under <Defer>
// Resolve the chunk first, then hydrate — the component is ready when the
// hydration walk reaches it, so it matches the server-rendered DOM.
await HeavyChart.preload();
defineApp(<App />).use(ssrClientPlugin).hydrate('#root');
For selective hydration where each island owns its own lazy chunk, the islands
plugin handles this for you — see
@sigx/ssr-islands.
The plugin SPI
The core renderer and hydrator are strategy-agnostic. Every advanced hydration
strategy — selective, islands, resumable, Defer — is an SSRPlugin with
optional server and client hook sets.
- Register a hand-written
SSRPlugin's server hooks by passing it tocreateSSR({ plugins: [myPlugin] }). (Published packs likeislandsPlugin()areSSRPacks — anSSRPluginplus aninstall(app)method — so you install those on the app withapp.use().) - Register client hooks with
registerClientPlugin(plugin).
import { createSSR, type SSRPlugin } from '@sigx/server-renderer';
const myPlugin: SSRPlugin = {
name: 'my-strategy',
server: {
// mutate/replace a component's context after it's built, before setup()
transformComponentContext(ctx, vnode, componentCtx) {
return; // accept as-is
},
// skip a component's setup/render and emit a placeholder string
suppressComponentRender(id, vnode, ctx) {
return; // render normally
},
// choose block | stream | skip for async components, inject HTML, etc.
handleAsyncSetup(id, ssrLoads, renderFn, ctx) {
return { mode: 'stream' };
},
},
client: {
// return false to skip the default DOM walk (resumable SSR)
beforeHydrate(container) {
return; // run normal hydration
},
// hydration-time mirror of server.transformComponentContext
transformComponentContext(vnode, componentCtx) {
return; // accept as-is
},
// return a Node to "claim" a component during the hydration walk
hydrateComponent(vnode, dom, parent) {
return undefined; // let core hydrate it
},
},
};
const html = await createSSR({ plugins: [myPlugin] }).render(<App />);
Key hook semantics:
server.transformComponentContextruns after a component's context is built and beforesetup(), letting a plugin mutate or replace it — e.g. swapctx.signalfor a state-capturing variant.client.transformComponentContextis its hydration-time mirror (same timing, noSSRContextargument), so a strategy can swapctx.signalfor a state-restoring variant. The pair keeps render and hydration symmetric while core stays strategy-agnostic.server.suppressComponentRenderruns aftertransformComponentContextand beforesetup(); returning an object skips the component's setup/render and emits the (optional)placeholderstring in its place. This is how islands makeclient:onlyship no server HTML — an empty<div data-island>placeholder. Works in both string and streaming modes.client.beforeHydratereturningfalseskips the default DOM walk — the basis for resumable SSR.client.hydrateComponentreturning aNodeclaims that component — the hook islands use to interceptclient:*props and schedule deferred hydration.server.handleAsyncSetupchoosesblock,stream, orskipper async component;getInjectedHTMLandgetStreamingChunkslet a plugin emit extra markup or streamed chunks.
You usually don't write this by hand. The islands strategy —
client:*directives, deferred hydration, signal-state transfer and per-island code splitting — is already implemented as a plugin in@sigx/ssr-islands. Reach for the raw SPI only when building a new strategy.
