Weftv0.23.1
GitHub

Server-Side Rendering

Weft renders on the server and hydrates on the client: the server produces HTML (plus inline data), and the browser adopts that existing DOM in place rather than re-creating it. Boundary.rpc extends this to rpc-backed server data — resolve an rpc on the server, serialize its result into the HTML, replay it on the client without a second request, and then keep the region live for refetch.

The two halves

  • Server@weftui/dom/server renders an app node to an HTML string (or stream). The hydratable variants additionally emit the inline data each reactive region and Boundary.rpc needs to resume on the client.
  • Client@weftui/dom/client's hydrate walks the server DOM, adopts it, wires up reactivity and event handlers, and resumes from the inline data. It does not re-render from scratch.
typescript
// server entry
import { renderToStringHydratable } from "@weftui/dom/server";
import { Effect } from "effect";
import { App } from "./app";

export const render = (): Promise<string> => Effect.runPromise(renderToStringHydratable(App()));
typescript
// client entry
import { hydrate } from "@weftui/dom/client";
import { Effect } from "effect";
import { App } from "./app";

const root = document.getElementById("root")!;
void Effect.runPromise(hydrate(App(), root));

The same side-effect-free App is imported by both entries — splice the server HTML into your template's outlet, ship it, and let the client entry hydrate it.

@weftui/dom/server exports four renderers:

String Stream
Plain (no JS / no hydration) renderToString renderToStream
Hydratable (emits inline payloads) renderToStringHydratable renderToStreamHydratable

Use a hydratable renderer whenever the client will call hydrate. The plain renderers produce complete, JS-free HTML with no payload scripts.

Loading server data with Boundary.rpc

SSR's natural companion is Boundary.rpc: it resolves an rpc on the server, serializes the result into the same HTML this page produces, and replays it on the client during hydrate — no second request, no fallback flash — then keeps the region live for refetch. It is the data half of the same server/client split described above: the rpc contract (pure Schema) is shared, while its handler lives in a server-only Layer the client never imports.

typescript
import { Boundary, h } from "@weftui/core";
import { Stream } from "effect";
import { GetStock } from "./data/inventory";

const StockPanel = (productId: number) =>
  Boundary.rpc(
    GetStock,
    () => ({ id: productId }), // a fresh typed payload per call (SSR / refetch / mount)
    (resource) =>
      h.p([
        "in stock: ",
        h.span([Stream.map(resource.value.changes, (stock) => String(stock.units))]),
        h.button({ type: "button", onclick: () => resource.refetch }, "Refresh"),
      ]),
    { fallback: h.p("loading stock…") }, // shown only on a client-first SPA mount
  );

Under SSR the server resolves the rpc in-process, successSchema-encodes the result inline as <script type="application/json">, and renders in place; hydrate reads that payload positionally, seeds the Resource, and adopts the DOM without re-calling the rpc (replay, never retry). The full model — the contract/handler split, router wiring, the four lifecycles, the Resource handle, and typed-failure replay — lives in one place: the RPC Data Boundaries guide. This page does not repeat it.

Note. Boundary.rpc resolves through the ambient AppRpcClientTag seam, which @weftui/router provides on both sides. In a router-less mount there is no seam, so the boundary resolves to a descriptive "needs router/rpc" error (not a defect).

When to use

  • Boundary.rpc — data that must be resolved on the server (behind a server-only service, credential, or private network) and rendered into the initial HTML, then refreshable on the client (refetch / client-first SPA mount) over the same rpc.
  • Boundary.suspend — async data that loads on the client (or streams the shell then fills); see the Boundary API.

See also