Skip to content

Server-Side Rendering

Render components on the server and hydrate on the client with seamless state transfer.

Overview

Xote provides full server-side rendering (SSR) with client-side hydration. You can render your components to HTML on the server, transfer state to the client, and hydrate the server-rendered DOM without re-rendering.

The SSR system is built on three core modules:

  • SSR — renders components to HTML strings
  • SSRState — serializes and restores state between server and client
  • Hydration — walks server-rendered DOM and attaches reactivity

Environment Detection

The SSRContext module provides runtime detection of the current environment:

open Xote

// Boolean checks
let isServer = SSRContext.isServer
let isClient = SSRContext.isClient

// Conditional execution
SSRContext.onServer(() => {
  Console.log("Running on the server")
})

SSRContext.onClient(() => {
  Console.log("Running in the browser")
})

// Environment branching
let greeting = SSRContext.match(
  ~server=() => "Rendered on server",
  ~client=() => "Running in browser",
)

This is useful for code that should only run in one environment, like DOM APIs on the client or file system access on the server.

Rendering to HTML

The SSR module renders Xote components to HTML strings:

open Xote

let app = () => {
  <div>
    <h1> {Node.text("Hello from the server!")} </h1>
    <p> {Node.text("This was rendered as HTML.")} </p>
  </div>
}

// Basic render to string
let html = SSR.renderToString(app)

// With a root element ID for hydration
let html = SSR.renderToStringWithRoot(app, ~rootId="root")

Full Document Rendering

For a complete HTML document with head, scripts, and styles:

let html = SSR.renderDocument(
  ~head=`
    <title>My App</title>
    <meta name="description" content="My Xote app" />
  `,
  ~scripts=["./client.mjs"],
  ~styles=["./styles.css"],
  ~stateScript=SSRState.generateScript(),
  app,
)

The rendered HTML includes comment-based hydration markers (<!--$-->, <!--#-->) that the hydration walker uses to attach reactivity without re-rendering.

State Transfer

The SSRState module handles serializing state on the server and restoring it on the client. It uses a type-safe Codec system for encoding and decoding values.

Creating Synced State

open Xote

// SSRState.make creates a signal and registers it for sync
let count = SSRState.make("count", 0, SSRState.Codec.int)
let name = SSRState.make("name", "Alice", SSRState.Codec.string)

// On the server: the signal is created with the initial value,
// and registered for serialization.
// On the client: the signal is restored from the server-serialized
// value embedded in the HTML.

Built-in Codecs

SSRState.Codec provides codecs for common types:

  • Codec.int, Codec.float, Codec.string, Codec.bool
  • Codec.array(itemCodec) — arrays of any codec type
  • Codec.option(itemCodec) — optional values
  • Codec.tuple2(a, b), Codec.tuple3(a, b, c) — tuples
  • Codec.dict(valueCodec) — dictionaries
// Array of strings
let items = SSRState.make(
  "items",
  ["Apple", "Banana"],
  SSRState.Codec.array(SSRState.Codec.string),
)

// Optional value
let selected = SSRState.make(
  "selected",
  None,
  SSRState.Codec.option(SSRState.Codec.int),
)

// Tuple
let position = SSRState.make(
  "pos",
  (0, 0),
  SSRState.Codec.tuple2(SSRState.Codec.int, SSRState.Codec.int),
)

Syncing Existing Signals

If you already have a signal, use SSRState.sync to register it:

let count = Signal.make(0)

// Register the signal for server/client sync
SSRState.sync("count", count, SSRState.Codec.int)

Generating the State Script

On the server, call SSRState.generateScript() to produce a <script> tag that embeds the serialized state in the HTML. Pass it to SSR.renderDocument via the ~stateScript parameter.

Client-Side Hydration

Hydration walks the server-rendered DOM and attaches reactive effects, event listeners, and keyed list reconciliation — without re-rendering:

open Xote

// Hydrate by container element ID
Hydration.hydrateById(app, "root", ~options={
  onHydrated: () => Console.log("Hydration complete!"),
})

// Or hydrate with a DOM element reference
Hydration.hydrate(app, containerElement)

After hydration, the app becomes fully interactive. Subsequent updates use standard client-side rendering.

Complete Example

Here is a full SSR setup with a shared component, server entry, and client entry.

Shared Component (App.res)

open Xote

let makeAppState = () => {
  let count = SSRState.make("count", 0, SSRState.Codec.int)
  let items = SSRState.make(
    "items",
    ["Apple", "Banana", "Cherry"],
    SSRState.Codec.array(SSRState.Codec.string),
  )
  let inputValue = Signal.make("")
  (count, items, inputValue)
}

let app = (count, items, inputValue) => () => {
  let increment = (_: Dom.event) =>
    Signal.update(count, n => n + 1)
  let decrement = (_: Dom.event) =>
    Signal.update(count, n => n - 1)

  <div>
    <h1> {Node.text("SSR Demo")} </h1>
    <p>
      {Node.text("Count: ")}
      {Node.signalText(() =>
        Signal.get(count)->Int.toString
      )}
    </p>
    <button onClick={decrement}>
      {Node.text("-")}
    </button>
    <button onClick={increment}>
      {Node.text("+")}
    </button>
    <ul>
      {Node.list(items, item =>
        <li> {Node.text(item)} </li>
      )}
    </ul>
  </div>
}

Server Entry (server.res)

open Xote

let (count, items, inputValue) = App.makeAppState()
let appComponent = App.app(count, items, inputValue)

let html = SSR.renderDocument(
  ~head=`<title>My App</title>`,
  ~scripts=["./client.res.mjs"],
  ~stateScript=SSRState.generateScript(),
  appComponent,
)

Console.log(html)

Client Entry (client.res)

open Xote

let (count, items, inputValue) = App.makeAppState()
let appComponent = App.app(count, items, inputValue)

let _ = Hydration.hydrateById(appComponent, "root", ~options={
  onHydrated: () => Console.log("Hydration complete!"),
})

Note: The shared component uses SSRState.make so the same code works on both server and client. On the server, signals are created with initial values and registered for serialization. On the client, they are automatically restored from the serialized state.

How Hydration Markers Work

During SSR, Xote inserts HTML comment nodes to mark reactive boundaries:

  • <!--$--> — signal text boundary
  • <!--#--> — signal fragment boundary
  • <!--kl--> — keyed list start
  • <!--k:KEY--> — keyed list item with key
  • <!--lc--> — lazy component boundary

The hydration walker reads these markers to know where to attach effects, event listeners, and reconciliation logic. This avoids re-rendering the DOM from scratch.

Best Practices

  • Use SSRState.make for shared state: It handles both creation and sync in one call
  • Guard client-only code: Use SSRContext.onClient for DOM APIs, timers, and browser features
  • Keep components isomorphic: The same component function should work on both server and client
  • Don't sync ephemeral state: Input values and UI state that resets on page load don't need SSRState
  • Match component trees: The client must render the same component tree as the server for hydration to succeed

Next Steps

Was this page helpful?