~2.99 KB (real Rollup phaze chunk; per-module attribution in SIZES.md)
5.31 KB (@preact/signals + Preact + bench frame)
50.49 KB (react-dom)
Hydration shipped in core
yes
yes
yes
Separate package to enable SSR
no
preact-render-to-string
react-dom/server
Bundle delta to flip client:only → SSR
0 bytes
0 bytes
0 bytes
Hydration mismatch behavior
graceful fallback
console warning
throws (React 18+)
The “sub-3 KB with SSR baked in” line isn’t marketing — it’s the per-module attribution of a real Astro app shipping client:only islands today. The hydrate.js module is already in the bundle (~1 KB raw / ~400 brotli) because @madenowhere/phaze-astro/client.ts imports it unconditionally and picks between render and hydrate at runtime. Switching any island to SSR flips a runtime branch — no chunk change.
A component is written once and runs in both places — on the server to generate the HTML, on the client to wire up behavior. The same code, JSX, and signals, both sides. So there’s:
No "use client" / "use server" boundary to draw, no function-coloring, no “is this a server component or a client component?” decision up front — and no second copy of a component to keep in sync. A default component is universal.
No coloring cascade. Adding interactivity to a server-rendered component doesn’t force it to “become a client component” and re-color everything that imports it (the tax RSC’s boundary imposes). You just add an effect().
A simpler mental model. “Where does this run?” collapses to “is this structure or behavior?” — and you rarely answer even that consciously: structure (JSX) is server-generated; behavior (anything in an effect() / ref / use:) defers to the client on its own.
The one place client/server stays explicit is server-only data and logic — loader / head / headers / phaze:actions / phaze:env — which run only on the server and never ship to the browser. That’s an opt-in export, not a color painted across the whole component. Everything else is universal, and the runtime decides when each half runs.
The isomorphism is literal — one JSX runtime, two phases.
On the server — generate.@madenowhere/phaze-render-to-string runs that same runtime against a DOM provided by linkedom and serialises the result to an HTML string:
No “server JSX runtime” is maintained separately — components written for the client work unchanged. The only server-side cost is the linkedom dep (~50 KB unminified, never reaches the browser; on Cloudflare Workers it’s bundled into the Worker, outside the client-bundle budget entirely).
On the client — hydrate.hydrate(component, container) (from the main entry, or the phaze/hydrate subpath) runs the same runtime again, but adopts the existing SSR’d DOM instead of creating it fresh:
// Walks the existing children in parallel with JSX construction, adopting
// each by position + tag match. Bindings + listeners attach to the adopted
// nodes. Returns a dispose function.
The cursor is a stack of { parent, child, ssrLeftover } frames: peek the next existing child; tag matches → adopt() (advance the outer cursor, push a frame for its children) and recurse; on exit, unclaimed SSR children are pruned. Comments are skipped; whitespace text is preserved (it could be intentional spacing between inline elements).
Same component, same runtime — the server generates, the client adopts.
Anything you’d put in an effect(), a ref callback, or a use: directive body runs only when there’s a real DOM to receive it — i.e. on the client, after the ref resolves. The server builds the HTML; the behavior wires up on hydrate. So all of these SSR fine, as long as they live in an effect / ref / directive rather than at construction time:
a signal<HTMLElement>() ref — undefined server-side, so the directive’s effect() no-ops, then attaches client-side when the ref fires
reactive attributes (class:foo, phaze:class) — the thunk evaluates at SSR with the initial signal value, then re-evaluates client-side as signals change
The proof: graviton’s warp(signal) SSRs a device-orientation-driven 3D-tilt headline — the rAF + sensor work is deferred inside the effect, while the headline text is in the SSR’d HTML for SEO. Rich interactivity, fully server-rendered. (See the speed primer’s “you don’t opt out of SSR for interactivity”.)
Reserve it for components that genuinely can’t render on the server — a WebGPU / Canvas / WebGL surface that needs a real GPU context, geolocation, anything whose construction requires the browser. ssr={false} render-gates it (the server emits nothing, the client mounts fresh) and lets the build strip it from the server bundle. It means “can’t run server-side,” not “is interactive” — interactivity is just deferred behavior, which SSRs. (Orthogonal axis: defer:idle / defer:visible / defer:media defer when an SSR’d subtree hydrates — see the DSL reference.)
ssr={false} is also the declarative replacement for the old manual SSR guard. Instead of sprinkling if (import.meta.env.SSR) return around browser-API access inside a browser-only component, mark the component ssr={false} — it never runs server-side, so there’s nothing to guard. (For browser-only behavior inside an otherwise-SSR’d component, defer it instead — keep it in an effect/ref that only resolves client-side, the way warp(signal) does.)
@madenowhere/phaze-astro wires both ends. The integration registers Phaze as a renderer with clientEntrypoint + serverEntrypoint; the client entry picks render vs hydrate at mount:
// inside @madenowhere/phaze-astro/client.ts
const mount = element.childNodes.length > 0 ? hydrate : render
Astro emits the SSR’d HTML inside the <astro-island> element when a directive is client:load/client:idle/client:visible/client:media. phaze-astro/client.ts sees the children and picks hydrate. With client:only, the island has no children → render runs fresh. The branch is at runtime; both code paths ship in the bundle either way.
Hydration mismatch — what happens when DOM and JSX disagree
Phaze’s stance is graceful degradation, not warnings or throws. If the next existing child in the cursor doesn’t match the expected tag, hydration falls back to fresh creation for that node and leaves the outer cursor untouched, so subsequent siblings still get a chance to adopt. No console.warn, no error thrown.
// SSR'd HTML: <div><span>old</span></div>
// JSX: <div><p>new</p></div>
// Hydration result: <div> is adopted, <span> doesn't match <p> →
// <p>new</p> created fresh and inserted, leftover <span>old</span>
// pruned by exit().
This contrasts with React 18+, which throws on mismatch by default (and Preact, which warns). The argument for graceful: SSR/client divergence is usually a real bug, but the right time to surface it is in development (e.g. @madenowhere/infrared could log mismatches once it lands), not by crashing production. Phaze’s runtime stays out of the user’s way; tooling surfaces the issue.
Phaze’s JSX evaluates leaf-first — children are constructed before the parent component’s body runs — but the hydration cursor walks top-down. The two had to be reconciled at component boundaries, where <Outer><Inner/></Outer> evaluates <Inner/>’s JSX before Outer’s body runs, so the cursor’s notion of “where we are” would normally be misaligned with where Outer’s wrapper element ends up.
The runtime resolves this inside appendDynamicChild — the path that handles thunked children emitted by phaze-compile’s auto-wrap pass for component JSX children, and the path that handles the default <For> after its compile-time inversion to {() => items().map(...)}. Two cooperating mechanisms:
Untracked peek adopts the cursor. The first call to the thunk runs under untrack() so signal reads don’t subscribe yet, but the hydration cursor still walks normally and adopts every SSR’d node the thunk produces. The result is captured as first.
withoutHydration(fn) re-runs the thunk to subscribe. Inside the dynamic child’s effect, the first run calls the thunk again with the hydration stack temporarily emptied. That call builds throwaway orphan DOM (immediately GC’d) — its only purpose is to register the effect’s subscription to the signals the thunk reads. The mount set uses first, so the SSR’d nodes stay in place; subsequent signal changes fire the effect, which rebuilds the children normally.
Net result: wrapping Phaze components work under SSR.<MyWrapper><div ref={…}/></MyWrapper> adopts cleanly, ref callbacks fire on the adopted DOM, signal bindings update the SSR’d text in place. No second component (<MyWrapperSSR>-style) is needed — the same component code handles both client:only (fresh render) and client:load/client:idle/client:visible (hydrate) without API divergence.
The mechanism above means Pattern B thunks — ones whose body reads signals to swap shapes — work correctly under hydration. The thunk re-runs inside withoutHydration(fn) on first effect, which subscribes the effect to every signal read. The first signal change after hydration triggers the swap.
// Inside an SSR'd subtree — works correctly post-hydrate:
This is also why the default <For for:item={items}> inversion ({() => items().map(...)}) hydrates correctly inside intrinsic-element parents: the items() read inside the arrow subscribes the effect via the same withoutHydration call, so items.set(...) triggers a rebuild after the SSR’d rows are adopted in place. See <For> › default form for the surface API.
Intrinsic-element parents and inversion-style dynamic children
One subtlety the withoutHydration mechanism handles: when the dynamic child sits as a direct child of an intrinsic element (e.g. <ul>{() => items().map(...)}</ul>), JavaScript’s leaf-first evaluation order means the inner peek runs before the parent <ul>’s frame is pushed. The peek silently falls back to fresh creation, and first contains orphan nodes rather than cursor-adopted ones. The dynamic child’s effect inserts the orphans into its (also-fresh) parent via the same insert-before-anchor loop the non-hydration path uses; the outer <ul> then adopts the SSR’d root via its peek, the fresh subtree gets re-parented in, and the SSR’d subtree is pruned by exit(). The user-visible result matches the M2 “root adopted, children fresh” semantics — continuous DOM at the root, no flash, correct content. The full “identity for every node” version waits on a future lazy-children compile pass.
The SSR’d HTML contains your text. After astro build, grep the output:
Terminal window
grep"Predict the peak"dist/client/index.html
# → match
If your hero/headline text is missing from index.html, the island is still client:only and Google’s first-wave crawler won’t see it.
The hydration adopts cleanly (no flicker). With Chrome DevTools open on Network throttling, navigate to the page. The text should paint before JS executes. After the runtime arrives, the text should stay put — no re-render flash, no layout shift.
For a known-working reference, see examples/astro-cloudflare which exercises the SSR → hydrate path against a KV-backed loader.
depends on host (Astro provides this layer above Phaze)
yes
Mismatch behavior
graceful fallback
throws
Cost model
adopts in place, no diff phase, no fiber tree built
reruns the component to match against existing DOM
Runtime bundle (brotli, framework + SSR support)
~2.99 KB
~50 KB
For Astro specifically: Astro itself provides the per-island priority directives (client:load / client:idle / client:visible / client:media), so the “streaming + island prioritization” gap is filled at the framework layer above Phaze.
If you’re using @astrojs/phaze (Astro page) or @madenowhere/phaze-cloudflare, SSR is wired automatically — no extra config. The renderer registration covers both the server entrypoint (@madenowhere/phaze-astro/server → phaze-render-to-string) and the client entrypoint (@madenowhere/phaze-astro/client → hydrate/render branch).
For standalone Vite or other tooling, import renderToString from @madenowhere/phaze-render-to-string to generate HTML server-side and hydrate from @madenowhere/phaze/hydrate on the client.
hydrate() on an empty container falls back to fresh render() — safe as the default mount function. The cursor’s peekChild returns null for every JSX element, every node gets created fresh, behaviorally identical to render().
Returns a dispose that tears down reactive bindings and aborts node listeners on unmount.
@madenowhere/phaze-cloudflare layers an edge-signal payload (window.__PHAZE_CF__) on top — values resolved on the Cloudflare edge during SSR are seeded into client signals without a hydration-time fetch round-trip. See edgeSignal() in the Cloudflare integration docs.