SSR
The router is isomorphic: route matching, loaders, and redirects run identically on the server. The SSR layer is ported 1-1 from upstream and works at the router level — but the upstream streaming pipeline is designed around framework render integrations, so wiring it to Domphy is manual. This page shows the honest, minimal path.
Entry Points
import { createRequestHandler, attachRouterServerSsrUtils } from "@domphy/router/ssr/server"
import { hydrate, json, mergeHeaders } from "@domphy/router/ssr/client"@domphy/router/ssr/server—createRequestHandler,attachRouterServerSsrUtils(dehydration + streaming utilities),transformStreamWithRouterand friends@domphy/router/ssr/client—hydratefor restoring dehydrated router state, plusjson/mergeHeadershelpers
The Minimal Pattern
The simplest reliable approach skips the streaming pipeline: run the router per request, render with Domphy SSR, and serialize what you need yourself.
Server — one router per request, memory history at the request URL, and isServer: true:
import { createRouter, createMemoryHistory } from "@domphy/router"
import { ElementNode } from "@domphy/core"
async function renderPage(requestUrl: string) {
const router = createRouter({
routeTree,
history: createMemoryHistory({ initialEntries: [requestUrl] }),
isServer: true,
})
await router.load() // matches + loaders, server-side
// A server load reports its outcome on `router._serverResult`: either a
// redirect to send back, or a render with the status code to use.
const result = router._serverResult
if (result?.type === "redirect") {
return {
redirect: result.redirect.headers.get("Location"),
status: result.redirect.status,
}
}
syncRouterState() // seed the bridge states so generateHTML sees the data
const node = new ElementNode(App)
const html = node.generateHTML()
const css = node.generateCSS()
const loaderData = router.state.matches.map((match) => ({
id: match.id,
loaderData: match.loaderData,
}))
return {
status: result?.status ?? 200, // 200, or 404 when a match was notFound
body: `<!doctype html>
<html>
<head><style id="domphy-style">${css}</style></head>
<body>
<div id="app">${html}</div>
<script>window.__ROUTER_STATE__ = ${JSON.stringify(loaderData)}</script>
<script type="module" src="/client.js"></script>
</body>
</html>`,
}
}isServer: true is not optional in this manual pattern. It routes load() through the server pipeline, which reports redirects via _serverResult instead of committing them to history. A router left in client mode runs the client pipeline, which tries to navigate to the redirect target — and a server history ignores navigation, so a redirecting route loads forever.
(In a Node/Deno/Bun/workerd process the router infers this anyway — isServer defaults to typeof document === "undefined" — so the option matters when a document exists, such as a jsdom-based test or SSR harness. createRequestHandler does not rely on the inference: it owns the server history, so it forces isServer: true onto the router it is given.)
Client — same route tree, browser history, mount instead of render:
import { createRouter, createBrowserHistory } from "@domphy/router"
import { ElementNode } from "@domphy/core"
const router = createRouter({ routeTree, history: createBrowserHistory() })
// Optional: seed match data from the server so loaders with staleTime skip refetching
hydrateLoaderData(window.__ROUTER_STATE__)
await router.load()
syncRouterState()
const domStyle = document.getElementById("domphy-style") as HTMLStyleElement
new ElementNode(App).mount(document.getElementById("app")!, domStyle)Create the router per request on the server — a module-level router would leak state between users.
Dehydrate / Hydrate Concept
The full SSR layer can do this serialization for you: on the server, attachRouterServerSsrUtils(router, ...) collects matches, loader data, and deferred promises into a dehydrated payload (serialized with seroval, so it handles promises and streaming); on the client, hydrate(router) from @domphy/router/ssr/client restores it before the first router.load(), so loaders do not re-run for data the server already fetched. createRequestHandler wraps the whole request lifecycle (including redirect responses).
These utilities work, but they assume a streaming HTML render to interleave with — Domphy's generateHTML is synchronous, so until a dedicated integration exists, the manual pattern above is the recommended route. See the API reference for the full SSR entry point exports.
Scroll Restoration
On the client, enable scroll restoration once after creating the router:
import { setupScrollRestoration } from "@domphy/router"
const router = createRouter({ routeTree, history: createBrowserHistory(), scrollRestoration: true })
setupScrollRestoration(router)For SSR pages, @domphy/router/scroll-restoration-script exports getScrollRestorationScriptForRouter(router) — an inline script to embed in the server HTML <head> so the scroll position is restored before hydration, avoiding a visible jump. (The export resolves to a no-op stub in browser builds.)