Skip to content

C7 — Hydration: server-render first, then adopt client-side

Prerequisite: a machine set up for client-WASM builds — rustup target add wasm32-unknown-unknown, cargo install wasm-pack, and the fitz CLI (v0.41.4+). Reading C1 helps; this chapter is otherwise self-contained.

Objective: build a component that paints on the server (fast first paint, works with JavaScript off) and then a WebAssembly bundle adopts that exact DOM client-side — from one .fitzv. You'll see the server HTML, the adopt on boot, and interactivity with a preserved caret.

Why it matters: the whole course so far was SSR — the server renders and diffs over a WebSocket (C1–C6). Client-WASM is the opposite — 100% in the browser, blank first paint. Hydration is both: the server paints first (SEO, no flash), then the same component becomes interactive locally with no round-trip. It's the bridge between the two halves of the frontend story, and it costs you one keyword.

Reference page: Hydration (SSR → client) — the capability and the three live demos. This chapter builds the first one from scratch.

1. The idea, in one picture

  server                          browser
  ──────                          ───────
  App.fitzv ──fitz run──▶  <div id="app"> …server HTML… </div>   ① first paint (no JS)
                          + <script id="__flv_state_App">{…}</script>
  App.fitzv ──fitz build─▶  app.wasm  ──init()──▶  App::hydrate(#app)   ② adopt, don't rebuild
                                                    · restore state from the <script>
                                                    · walk the existing nodes (no create_element)
                                                    · wire the listeners

Two compilations, one source. The same App.fitzv becomes the server HTML and the wasm bundle that adopts it. If the mount root is empty (no server HTML), start() falls back to a fresh client mount — so the bundle still works as a standalone SPA.

2. Create the project

mkdir hydration-demo && cd hydration-demo

We'll write four files: the component, a manifest with two bins, a tiny prerender program, and the host page. Start with the component.

App.fitzv:

component App hydrate {
  state {
    label: Str = "shipping"
  }

  event on_label() { label = payload["value"] }
  event reset()    { label = "shipping" }

  <template>
    <div class="card">
      <p class="greeting">Label: <span class="lbl">{label}</span></p>
      <label class="row">
        <span>Type a label</span>
        <input class="inp" @input="on_label" value="{label}" placeholder="type here" autocomplete="off" />
      </label>
      <button class="btn" @click="reset">reset</button>
    </div>
  </template>
}

The hydrate marker

component App hydrate { … } — the marker after the name is the whole opt-in. It tells the SSR emitter to append a <script type="application/json" id="__flv_state_App"> state payload to the server HTML, so the wasm has the state to restore on boot. It's opt-in because components rendered for the WebSocket takeover (C1–C6) forbid a <script> in their diffed root — those stay byte-identical.

The shape rules

This is a keep-node component: a live control (@input) over a static template. One authoring rule makes the adopt line up 1:1 with the server DOM:

  • Sole-child interpolations. A dynamic {label} is the only child of its element (<span class="lbl">{label}</span>), so the server text node maps cleanly onto the adopt walk. That's why the label is wrapped in its own <span>, not written inline as Label: {label}.

We'll put the CSS in the host page's <head> for this demo (next section) — but a hydrating component can carry its own <style scoped> since core v0.41.5; the adopt walk skips the server-painted style block. See Styling & theming.

3. The manifest — two bins from one file

fitz.toml:

[package]
name = "hydration-demo"
version = "0.1.0"
edition = "2026"

[dependencies]
fitz_liveviews = { path = "../.." }   # or your dependency line

[[bin]]
name = "app"
main = "App.fitzv"
target = "wasm-client"   # the browser bundle that ADOPTS the DOM
mount = "#app"

[[bin]]
name = "prerender"
main = "prerender.fitz"  # prints the server HTML

Same App.fitzv, two targets: app (wasm-client) and — through prerender — the SSR emitter.

4. Generate the server HTML

prerender.fitz:

from fitz_liveviews import flv_register
from App import App, App_render, App_on_label, App_reset

let state = App { label: "Ada" }
print(App_render(state).raw)

from App import App_render compiles App.fitzv through the SSR emitter and App_render(state).raw prints the exact server HTML. The state is App { label: "Ada" }deliberately different from the component default "shipping" — so on boot you can prove the wasm restored state from the <script>, not the default.

fitz run --bin prerender

You'll see the <div class="card">…</div> with data-flv-* attributes and a trailing <script type="application/json" id="__flv_state_App">{"label":"Ada"}</script>. Copy that output — it's the #app content in the next step.

5. The host page

index.html — the --flv-* design tokens + component CSS in <head>, the server HTML pasted into #app, and a module script that boots the wasm:

<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <style>
      :root { --flv-color-primary: #ce412b; --flv-surface: #fff; --flv-text: #1a1a1a; }
      .card { display: flex; flex-direction: column; gap: .6rem; max-width: 22rem; }
      .inp  { padding: .4rem .6rem; }
    </style>
  </head>
  <body>
    <div id="app">
      <!-- PASTE the `fitz run --bin prerender` output here (verbatim) -->
    </div>
    <script type="module">
      // Tag a node BEFORE boot. If the wasm ADOPTS (not recreates), it survives.
      const span = document.querySelector('#app .lbl');
      if (span) span.__hydrationWitness = 'server-node';

      import init from './pkg/app.js';
      await init();
    </script>
  </body>
</html>

The __hydrationWitness line is a trick to see the adoption: we tag the label node before the wasm boots. If hydration reused it (rather than re-creating it), the property is still there afterwards.

6. Build and run

fitz build --bin app          # → target/wasm/app/, mirror app.js + app_bg.wasm to ./pkg/
python -m http.server 8000    # then open http://localhost:8000

Serve over HTTP (ES modules need an origin — file:// won't do).

Live version of this exact demo:

What you should see:

  • The label reads "Ada" on first paint — the server state, not the default "shipping". The wasm restored it from the <script>.
  • In the console: document.querySelector('#app .lbl').__hydrationWitness is still 'server-node' → the node was adopted, not recreated.
  • Typing in the input updates the label live and keeps the caret (keep-node patches the adopted node in place). reset restores "shipping".

7. Capstone — hydrate a composition with a region

Now the complex case: a tree that composes the real <Badge> companion and renders a {#for} region — both adopted on boot. This is the shape a real "status card with a list" takes. It's the examples/hydration-composition-regions/ demo, built piece by piece.

7a. Compose the real Badge

A hydrating tree can import a companion component from src/ui/ and compose it with <Child /> — the same source server-renders the Badge (its own scoped styles included) and the wasm adopts it across the parent/child boundary.

App.fitzv:

from fitz_liveviews.ui.Badge import badge as Badge

component App hydrate {
  state {
    label: Str = "active"
    variant: Str = "success"
    tags: List<Str> = ["build", "deploy", "verify"]
  }

  event toggle() {
    if (variant == "success") { variant = "muted" } else { variant = "success" }
    if (label == "active")    { label = "idle" }    else { label = "active" }
  }

  <template>
    <div class="flv-hcard">
      <div class="flv-hcard-row">
        <span class="flv-hcard-svc">pipeline</span>
        <Badge label="{label}" variant="{variant}" size="md" />
      </div>

      <ul class="flv-hcard-stages">
        {#for t in tags}
          <li class="flv-hcard-stage"><span class="tn">{t}</span></li>
        {/for}
      </ul>

      <button class="flv-btn" @click="toggle">toggle status</button>
    </div>
  </template>
}

Two things to notice:

  • from fitz_liveviews.ui.Badge import badge as Badge — the companion is aliased Badge so the tag is <Badge ... />. The prop values are interpolated from parent state (label="{label}"), so the Badge reflects the parent — and it updates when the parent re-renders.
  • The {#for t in tags} is a region. Its items are server-painted between <!--fr--> / <!--/fr--> comment anchors, and the adopt walk skips those anchors to leave the server-painted list in place.

7b. Naive composition — why @click, not @input

Composition hydrates naively: it adopts the whole tree on boot (no flash, server nodes preserved), but the first state change re-renders the tree wholesale — there's no in-place patch across a component boundary. That's why the interaction here is a @click toggle: a live @input would lose its caret on the re-render (keep those in a keep-node component, like §2–6). The {#for} region adopts on boot and rebuilds from state on each change.

7c. Prerender, build, and observe

Same loop as before — prerender.fitz imports the event fns too so the implicit registration resolves:

from fitz_liveviews import flv_register
from App import App, App_render, App_toggle

let state = App { label: "idle", variant: "muted", tags: ["clone", "test"] }
print(App_render(state).raw)
fitz run --bin prerender      # server HTML → paste into #app
fitz build --bin app          # wasm bundle → ./pkg/
python -m http.server 8000

Live version:

What you should see: on first paint the pill reads "idle" (muted) and the list is clone, test — the server state, not the defaults. A JS property tagged on the Badge node survives boot → the composed Badge was adopted across the boundary. toggle status re-renders the tree: the pill flips colour/label and the list is rebuilt.

7d. What's still out of scope

A <Child/> dynamically inside a {#for} — a list of composed children with keyed reconciliation — is not supported: it clashes with the naive wipe-and-rebuild model. The region here holds plain items and the composed <Badge> sits outside the loop.

Scoped styles on a hydrating root (since core v0.41.5): a hydrating component can carry its own <style scoped> — you don't have to move the CSS to the host <head>. See the Styling & theming guide.

Checkpoint

You should now have:

  • An App.fitzv with a hydrate marker that compiles two ways.
  • A page whose #app is server-painted HTML (works with JS off) and whose wasm bundle adopts it on boot — the label reads the server state, the tagged node survives, and the input keeps its caret.

You understand the whole loop: fitz run --bin prerender for the server HTML, fitz build --bin app for the adopting bundle, and the hydrate marker as the one-keyword opt-in.

Troubleshooting

  • The label shows the default, not the server value. The <script id="__flv_state_App"> payload isn't in #app, or you pasted the prerender output without it. Re-run fitz run --bin prerender and paste the whole output (the <script> is the last line).
  • The witness is gone / the input flashes on boot. The wasm fresh-mounted instead of adopting — usually the mount root was empty (no server HTML pasted), so start() fell back to mount(). Paste the server HTML into #app.
  • view emit error: … sole child … or the adopt is misaligned. A dynamic {expr} isn't the only child of its element. Wrap it: <span>{expr}</span>.
  • view emit error: … <style scoped> … on the hydrating root. Move the styles to the host page's <head>; the hydrating root ships no scoped block.
  • 404 on ./pkg/app.js. fitz build --bin app lands the bundle in target/wasm/app/; copy app.js + app_bg.wasm next to index.html under ./pkg/ (that's what the examples' build.sh does).

What's next

That's the client-side capstone. You've now seen all three rendering modes:

  • SSR / LiveView — server state, WebSocket diffing (C1–C6).
  • Client-WASM — 100% local, offline widgets.
  • Hydration — server first paint and local interactivity, from one source.

Where to go from here:

  • Hydration reference — the three live demos and the full list of what hydrates (keep-node, composition, regions) and the edges.
  • Component gallery — every control on one page.
  • Admin ABM — the flagship, for the full SSR stack.