Skip to content

Server-Side Rendering

Render on the server, transfer state explicitly, and hydrate without re-rendering.

Rendering Model

Overview

Xote's SSR story is built from three modules that line up with the render flow:

  • SSR - render nodes to HTML
  • SSRState - serialize state on the server and restore it on the client
  • Hydration - attach reactivity and event handlers to existing DOM

The key idea is that the client should not re-render what the server already produced. Hydration walks the server HTML, reconnects reactive boundaries, and continues from there.

Render on the Server

Use SSR.renderToString for fragments and SSR.renderToStringWithRoot when you want a hydration root.

1open Xote
2
3let app = () => {
4 <div>
5 <h1> {Node.text("Hello from the server")} </h1>
6 </div>
7}
8
9let html = SSR.renderToStringWithRoot(app, ~rootId="root")

Full Document Rendering

Use SSR.renderDocument when the server is responsible for the whole HTML document.

1let html = SSR.renderDocument(
2 ~head="<title>Xote App</title>",
3 ~scripts=["/client.js"],
4 ~stateScript=SSRState.generateScript(),
5 app,
6)

Environment Detection

Use SSRContext when code needs to branch between server and client behavior.

1let greeting = SSRContext.match(
2 ~server=() => "Rendered on the server",
3 ~client=() => "Rendered on the client",
4)

State and Hydration

State Transfer

If the server and client must start from the same state, register signals with SSRState.

Creating Synced State

1let count = SSRState.make("count", 0, SSRState.Codec.int)
2let name = SSRState.make("name", "Ada", SSRState.Codec.string)

Syncing Existing Signals

1let draft = Signal.make("")
2SSRState.sync("draft", draft, SSRState.Codec.string)

Built-in Codecs

Use the built-in codecs for primitives and common containers, or create your own with SSRState.Codec.make.

1SSRState.Codec.int
2SSRState.Codec.string
3SSRState.Codec.bool
4SSRState.Codec.array(SSRState.Codec.string)
5SSRState.Codec.option(SSRState.Codec.int)
6SSRState.Codec.tuple2(SSRState.Codec.int, SSRState.Codec.string)

On long-lived servers that render more than one request, call SSRState.clear() between renders to reset the registry.

Client-Side Hydration

Hydration connects the client runtime to server-rendered DOM without replacing it.

1Hydration.hydrateById(app, "root", ~options={
2 onHydrated: () => Console.log("hydrated"),
3})

In Practice

Complete Example

A typical setup shares the same component between server and client.

Shared Component

1open Xote
2
3let count = SSRState.make("count", 0, SSRState.Codec.int)
4
5let app = () => {
6 <div>
7 <h1>
8 {Node.signalText(() => "Count: " ++ Int.toString(Signal.get(count)))}
9 </h1>
10 <button onClick={_ => Signal.update(count, n => n + 1)}>
11 {Node.text("Increment")}
12 </button>
13 </div>
14}

Server Entry

1Router.initSSR(~pathname="/", ())
2
3let html = SSR.renderDocument(
4 ~scripts=["/client.js"],
5 ~stateScript=SSRState.generateScript(),
6 app,
7)

Client Entry

1Router.init()
2Hydration.hydrateById(app, "root")

Hydration Markers

Xote inserts HTML comments around reactive boundaries during SSR. The client uses those markers to find signal text, fragments, keyed lists, and lazy components while hydrating.

Working Style

Best Practices

  • Render the same component tree on the server and the client so hydration can attach cleanly.
  • Initialize the router for the right environment: initSSR on the server, init on the client.
  • Clear SSRState between server renders, especially in custom or long-lived processes.
  • Choose codecs deliberately because they define the contract between serialized output and restored client state.

Next Steps