SSR + Hydration
The server renders your pages to HTML, the browser parses that HTML into a DOM tree, and the framework adopts the existing tree on the client — it doesn't rebuild it. Subsequent updates mutate individual text nodes in place.
Why this matters
Most "SSR with hydration" frameworks render HTML on the server, ship it to the browser, then run the same component code again on the client just to build a parallel virtual-DOM tree they can diff against future renders. That second render is the part users feel as "the page froze for half a second after it loaded."
rogue skips the second render. The DOM the server produced is the working DOM. When a signal changes, the framework finds the specific text node or attribute that depends on it and updates that one node. There's no shadow tree being maintained on the side, no diff to compute, no client-side render to wait for.
Server side
For development, the rogueSsr() Vite plugin handles
SSR inside the dev server — npm run dev renders pages
server-side automatically. For production, you call
render() directly:
import { createServer } from 'vite'
import { render } from '@jjordy/rogue/server'
const vite = await createServer({
server: { middlewareMode: true },
appType: 'custom',
})
const { html, status } = await render('/blog/hello', vite)
render() does the obvious thing: finds the matching
route, runs its loader(), applies the layout chain,
renders the JSX to a real DOM (via
linkedom),
then serializes it. The serialized output looks like normal HTML
with one twist: each shadow root is emitted as a
Declarative Shadow DOM template.
Declarative Shadow DOM
DSD is a relatively new browser feature: a
inside a
custom element tells the parser "attach this as a shadow root to
the parent before any JS runs." That means:
- The browser paints styled, fully-structured content from the first response byte. No FOUC, no unstyled flash while JS catches up.
-
Your custom-element scoping (the whole point of shadow DOM) is
in effect during SSR — the browser doesn't need to wait for
attachShadow()to run before respecting your styles.
Production: skipping Vite at runtime
For deployed apps you don't want Vite in the request path. The
loadProdRenderer() helper boots SSR + actions from a
pre-built bundle:
import { loadProdRenderer } from '@jjordy/rogue/server/prod'
const { render, handleAction } = await loadProdRenderer({
clientDir: 'dist/client', // output of: vite build
serverDir: 'dist/server', // output of: vite build --ssr src/entry-server.ts
})
Two vite build invocations produce both bundles, and
loadProdRenderer reads the manifests once at startup.
No bundler is loaded per request.
Hydration: how the client adopts the server's DOM
The trick is matching the JSX template (which the compiler knows the shape of) to the SSR'd DOM (which has the slot contents filled in). To do this, the compiler emits a pair of comment anchors around every dynamic slot:
<span>Count: 0span>
The anchors and
bracket the slot content. The runtime
walks the template with a slot-aware navigation function that
treats each anchor pair as a single virtual child — so a path
like "third child of root, second child of that" lines up the
same way against the empty template (where the slot is empty) and
against the SSR'd DOM (where the slot contains
"0").
What "real hydration" means here
When a component boots client-side, instead of cloning a fresh template, it calls a function that returns the SSR'd root if it exists. The outermost component adopts the existing DOM — every node already in the page becomes the component's working tree.
The first time a reactive slot needs to display a value, it
collects whatever sits between the open and close anchors as its
current content. From then on, signal updates that resolve to the
same primitive type (text → text) mutate the existing text node's
nodeValue in place. No replaceChild, no DOM
reconstruction.
Why not just innerHTML?
Setting innerHTML on the client throws away the DOM
the parser produced and rebuilds it. Even if the rebuilt tree
looks identical, you lose: input cursor positions, scroll state on
scrollable children, in-progress CSS animations,
open/closed state. Adopting the
parsed tree avoids that entire class of problems.
The hydration logic is covered by a smoke test that runs on every change: SSR a page, parse the HTML into a DOM, attach the framework, click a button, assert the right text node mutated instead of being replaced.
See it visualized
The showcase page has a live hydration inspector — toggle it to highlight the slot anchors in the rendered DOM and watch nodes flash green when they mutate in place. Click a counter; only one text node lights up.