Server rendering — @zoijs/ssr
Render Zoijs components to an HTML string on the server — with no DOM and zero dependencies. Use it for server-side rendering (fast first paint, SEO) and static prerendering (build your site to flat HTML). The same component code runs on the server and the client; on the client, mount takes over.
npm i @zoijs/ssr # peer: @zoijs/core ^1.5.0Render to a string#
import { html, createState } from "@zoijs/core";
import { renderToString } from "@zoijs/ssr";
export function App() {
const name = createState("world");
return html`<main><h1>Hello, ${() => name.get()}!</h1></main>`;
}
renderToString(App); // → '<main><h1>Hello, world!</h1></main>'Each dynamic value is read once and serialized. Put the markup in your HTML shell:
import { renderToString } from "@zoijs/ssr";
import { App } from "./App.js";
const page = `<!doctype html>
<html>
<head><meta charset="utf-8"><title>My app</title></head>
<body>
<div id="app">${renderToString(App)}</div>
<script type="module" src="/client.js"></script>
</body>
</html>`;On the client, hydrate — adopt that server DOM in place. Render the markup with { hydratable: true } so the client can find and reuse it:
// server
<div id="app">${renderToString(App, { hydratable: true })}</div>// client.js
import { hydrate } from "@zoijs/ssr";
import { App } from "./App.js";
hydrate(App, "#app");hydrate() reuses the server's elements exactly — same nodes, never re-created — and attaches their events and reactive attributes in place. The page is interactive without a full re-render or a flash. It returns an unmount(), like mount.
Passing data to the client#
renderToString is synchronous, so a @zoijs/resource renders its loading state on the server. To skip the client-side refetch (and the flash), render with the data you already fetched, then hand it over with serialize — a JSON serializer that is safe to embed in a <script> (it escapes <, >, &, and the U+2028/U+2029 line terminators, so a </script> in your data can't break out):
import { renderToString, serialize } from "@zoijs/ssr";
// server: fetch, render with the value, embed it
const data = { user: await getUser() };
const body = renderToString(() => App(data), { hydratable: true });
res.end(`<div id="app">${body}</div>
<script>window.__DATA__ = ${serialize(data)}</script>`);// client: seed the resource — it starts settled and does NOT refetch
const user = resource(() => fetch("/api/user").then((r) => r.json()),
{ initial: window.__DATA__.user });The server rendered with the same value the client seeds with, so the markup matches and hydration is seamless.
Routed SSR (per-request)#
To render the right route for each request, pass the request URL to the router as { location }, and use router.match() to learn the route + params so you can load that route's data first:
import { renderToString, serialize } from "@zoijs/ssr";
import { createRouter } from "@zoijs/router";
async function handler(req, res) {
const router = createRouter(routes, { location: req.url });
const { params } = router.match(); // route + params for this request
const data = await loadData(params); // your per-request data loading
const body = renderToString(() => App(router), { hydratable: true }); // renders the route
res.end(`<div id="app">${body}</div>
<script>window.__DATA__ = ${serialize(data)}</script>`);
}The route component seeds its resource from that data — one expression that works on the server (set a global before rendering) and the client (the embedded <script>):
const User = (params) => {
const seed = (typeof window === "undefined" ? globalThis : window).__DATA__;
const user = resource(() => fetchUser(params.id), { initial: seed?.user });
return html`<h1>${() => user.data().name}</h1>`;
};That's the whole of "per-request loaders": location + match() plus serialize and { initial }. There's no loader API and no component-signature change — automatic loaders stay out of scope by design.
Static prerendering (SSG)#
renderToString needs no DOM and no running server, so you can render each route to flat HTML at build time — exactly how you'd ship a docs or marketing site with no runtime at all:
import { writeFileSync } from "node:fs";
import { renderToString } from "@zoijs/ssr";
import { routes } from "./routes.js";
for (const [path, Page] of Object.entries(routes)) {
writeFileSync(`dist${path}.html`, shell(renderToString(Page)));
}Same security as the client#
@zoijs/ssr reuses the exact security predicates the browser renderer uses (from the @zoijs/core/server subpath added in core 1.5.0), so there's no second escaping implementation to drift:
- Text and attribute values are escaped — interpolated data can't inject markup or break out of an attribute.
- URL attributes are scheme-checked —
javascript:and friends are dropped;data:is allowed only for raster images. - Event handlers and
refs are dropped — they're wired on the client bymount. - Unsafe attribute names (
on*,srcdoc) are refused.
See Security for the full model.
How hydration works#
hydrate() adopts the server DOM in place: the page's element structure — the expensive, layout-bearing part — is reused exactly, and events plus reactive attributes attach to those live nodes. Dynamic content regions (a text slot, an each list, a nested template) are cleared and re-rendered into that structure; because the values match the server, this is invisible — no flash. So the static shell is adopted byte-for-byte and only dynamic leaves re-render. If the markup doesn't match what the component produces, that region simply isn't made reactive (graceful degradation), never corrupted. And because the client path is pure @zoijs/core, removing @zoijs/ssr still leaves a working client app (take over with a plain mount). See RFC 0008.
Scope#
renderToString covers components built from html, each, conditionals, nested templates, and reactive values — the normal Zoijs view. A component that returns a raw DOM Node can't be server-rendered (there's no DOM); return html\…\`` instead.
That's the package tour. Explore the Cookbook for end-to-end recipes.