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.boolCodec.array(itemCodec)— arrays of any codec typeCodec.option(itemCodec)— optional valuesCodec.tuple2(a, b),Codec.tuple3(a, b, c)— tuplesCodec.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.onClientfor 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
- Learn about Signals — the reactive primitives behind SSR state
- Explore Components — building the component tree
- See the Technical Overview for architecture details