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.0

Render 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-checkedjavascript: and friends are dropped; data: is allowed only for raster images.
  • Event handlers and refs are dropped — they're wired on the client by mount.
  • 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.