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, or 307/308 for redirects
redirectredirect target, when a loader/middleware redirected
dataloader results, keyed for hydration
bootstrapScriptinline <script> exposing data to the client

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)

Node serves requests concurrently, so if your route tree renders any navLink, pass router: app.router to it explicitly here — see the navLink router prop warning for why.

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, {
  head: `<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">`,
  bootstrap: `<script type="module" src="/client.js"></script>`,
  headers: request.headers,
})

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> + <head> (your head option + shell CSS) + <body><div id="domphy-app"> wrapping the shell. The browser paints the loading UI right away.
  • 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. Because the shell is committed before loaders run, status is 200 for any matched route; loader-level notFound()/error render their boundaries inline.

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