Server Rendering
@domphy/app server-renders through Domphy core's generateHTML()/generateCSS() and hydrates with mount() — with routing, loaders, middleware and metadata handled for you.
renderToString
const app = createApp(routes)
const result = await app.renderToString(request.url, { headers: request.headers })SSRResult:
| Field | Meaning |
|---|---|
html | markup of the app root |
css | scoped CSS of the rendered tree |
head | serialized <title> / <meta> / <link> tags |
status | 200, 404, 500 (router error, e.g. a rewrite loop), or 307/308 for redirects |
redirect | redirect target, when a loader/middleware redirected |
data | loader results, keyed for hydration |
bootstrapScript | inline <script> exposing data to the client |
Deliberate gap:
SSRResulthas no response-header channel — pages cannot mutate response headers (noSet-Cookie) during SSR, unlike Next.js Server Actions /cookies().set().cookies()is the read path only; set cookies from an API route (web-standardResponse) or in your host server.
A Node Server
import http from "node:http"
import { createApp } from "@domphy/app"
import { themeCSS } from "@domphy/theme"
import { routes } from "./routes.js"
http.createServer(async (request, response) => {
const app = createApp(routes)
const result = await app.renderToString(`http://localhost${request.url}`)
if (result.redirect) {
response.writeHead(result.status, { location: result.redirect })
response.end()
return
}
response.writeHead(result.status, { "content-type": "text/html; charset=utf-8" })
response.end(`<!doctype html>
<html>
<head>
${result.head}
<style>${themeCSS()}</style>
<style id="domphy-style">${result.css}</style>
</head>
<body>
<div id="app">${result.html}</div>
${result.bootstrapScript}
<script type="module" src="/client.js"></script>
</body>
</html>`)
}).listen(3000)Concurrent renderToString / renderToStream calls each build their tree with that request's router on the render stack, so factory-created navLinks (no router prop) bind correctly. Pass router only when the link is created outside a render.
Hydration
client.js builds the same app and mounts onto the server markup. hydrate() reads the data embedded by bootstrapScript, so loaders are not re-run and the client tree matches the HTML byte for byte:
import { createApp } from "@domphy/app"
import { themeApply } from "@domphy/theme"
import { routes } from "./routes.js"
themeApply()
const app = createApp(routes)
const mountTarget = document.getElementById("app")!.firstElementChild as HTMLElement
const style = document.getElementById("domphy-style") as HTMLStyleElement
await app.hydrate(mountTarget, style)After hydration the router takes over: clicks on navLink anchors navigate client-side, loaders run on demand, metadata updates document.head.
Streaming
renderToStream trades the single renderToString string for a web ReadableStream that flushes in two phases: the shell (layouts wrapping each segment's loading fallback) goes out immediately for a fast first byte, then the resolved content, head and hydration data stream in once the loaders settle.
// `redirect` is optional (undefined when no redirect occurred)
const { stream, status, redirect } = await app.renderToStream(request.url, {
lang: "en", // `<html lang>`, default "en"
head: `<meta name="viewport" content="width=device-width, initial-scale=1">`,
bootstrap: `<script type="module" src="/client.js"></script>`,
headers: request.headers,
})
if (redirect) {
response.writeHead(status, { location: redirect })
response.end()
return
}
response.writeHead(status, { "content-type": "text/html" })
// Pipe the web stream to the Node response (or return it directly on edge runtimes).
for await (const chunk of stream as unknown as AsyncIterable<Uint8Array>) {
response.write(chunk)
}
response.end()renderToStream emits a full HTML document:
- First flush —
<!DOCTYPE html><html lang="…">+<head>(<meta charset="utf-8">, yourheadoption, shell CSS) +<body><div id="domphy-app">wrapping the shell. The browser paints the loading UI right away. The charset is emitted unconditionally: the stream is UTF-8 and a response served as plaintext/htmlwould otherwise be decoded as windows-1252. - Second flush — the content and head arrive as
<template>elements followed by an inline script that swaps them into place, then the hydration data and yourbootstrapmarkup.
RenderToStreamOptions adds head (markup for <head>, sent first) and bootstrap (markup before </body>, usually the client bundle <script>) to the headers option.
status/redirect are decided before the shell flushes: middleware and static route.redirect can still return 307/308 (or 404/500). A middleware that throws an ordinary error before the shell is answered with a 500 shell rendered by the error block, the same way renderToString answers it — renderToStream does not reject. Loader redirect() cannot change them — the first byte is already committed — and streams as a client-side location.replace in a later chunk. Use renderToString when you need an HTTP redirect from a loader. Loader notFound()/error render their boundaries inline in the second flush.
On the client, hydrate the swapped root exactly as with renderToString:
await app.hydrate(document.getElementById("domphy-app")!.firstElementChild as HTMLElement)TIP
Use renderToString when you want one buffered response (simplest, best for SSG and small pages) and renderToStream when slow loaders would otherwise delay the first byte — the shell paints instantly and content streams in.
Static Generation
renderToString is a pure function of URL + loaders, so SSG is a loop:
for (const url of ["/", "/about", "/blog/hello"]) {
const result = await app.renderToString(url)
await writePage(url, result)
}