Skip to content
Domphy

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:

FieldMeaning
htmlmarkup of the app root
cssscoped CSS of the rendered tree
headserialized <title> / <meta> / <link> tags
status200, 404, 500 (router error, e.g. a rewrite loop), or 307/308 for redirects
redirectredirect target, when a loader/middleware redirected
dataloader results, keyed for hydration
bootstrapScriptinline <script> exposing data to the client

Deliberate gap: SSRResult has no response-header channel — pages cannot mutate response headers (no Set-Cookie) during SSR, unlike Next.js Server Actions / cookies().set(). cookies() is the read path only; set cookies from an API route (web-standard Response) 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">, your head option, 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 plain text/html would 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 your bootstrap markup.

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)
}