SSR and hydration¶
Server-side rendering should produce the same UI that the browser later adopts. Fresco does not use a separate string-template implementation: the server renders the same views and React elements used by the client.
Every foreign or native component has one of two server policies:
- Render: run on the server and produce deterministic HTML;
- Client-only: do not run on the server; render a declared fallback or nothing until the browser takes over.
Hydration adopts the server's DOM and attaches the application. It does not replace the page with a fresh client mount when the two sides agree.
Render a page from a snapshot¶
The example page reads a feed from app-db and includes a browser-only chart:
(ns app.views
(:require [re-frame.fresco :as h]
["trend-charts" :refer [TrendChart]]))
(h/defhost trend-chart TrendChart
{:server :client-only
:fallback [:div
{:class "chart chart--pending"}
"Chart loads in the browser"]})
(h/defview article-row [{:keys [id]}]
[:li {:class "article"}
[:a {:href (h/sub [:article/url id])}
(h/sub [:article/title id])]])
(h/defview page [_]
[:main
[:h1 (h/sub [:feed/heading])]
[:ul
(for [id (h/sub [:feed/article-ids])]
[article-row {:id id :key id}])]
[trend-chart
{:points (clj->js (h/sub [:feed/trend]))}]])
No view code is specific to SSR.
Use the optional server module to render one request. The Node renderer is a
separate process from the browser, and nothing installs an adapter for it:
server/render mints a frame per request, and building that frame's state
container raises :rf.error/no-adapter-installed in a process where
rf/init! never ran — before any HTML is produced. Install the headless SSR
adapter once at process startup. It is boot work, not request work.
(ns app.server
(:require [re-frame.core :as rf]
[re-frame.ssr :as ssr]
[re-frame.fresco.server :as server]
[app.views :as views]
[app.subs]
[app.events]))
(rf/init! ssr/adapter) ;; once, at process startup — never per request
(defn page-response
"Render one request from an app-db snapshot."
[db-snapshot]
(:document
(server/render
{:hiccup [views/page {}]
:snapshot db-snapshot
:payload [:feed :session]
:client-frame-id :app/main
:identifier-prefix "main"
:app-element-id "app"
:script-src "/js/app.js"
:title "The feed"})))
server/render performs a fixed sequence:
- Create a fresh private frame for the request.
- Seed it through the normal framework doors:
:rf/set-dbfor:snapshot, followed by any:initial-eventsin order. - Render the React tree to HTML with
react-dom/server, applying each host and native component's server policy. - Build the hydration payload from the allowlisted app-db keys and embed it
as
__rf_payload. - Destroy the request frame in a
finallyblock, including when rendering throws.
Concurrent requests cannot read each other's app-db.
The payload is fail-closed¶
:payload must be either:
- a non-empty vector of top-level app-db keys; or
:rf.ssr.payload/whole-app-dbas an explicit opt-in.
Omitting it raises :rf.error/ssr-missing-payload-policy at service boot.
Allowlist every top-level key the rendered page reads. If the server rendered a
value that the client did not receive, the first client render uses different
state and the resulting hydration mismatch is real.
The HTTP service returns :document.
Server render rules¶
A server render performs cold subscription reads against one immutable snapshot. It does not register live subscriptions, commit React work, or run client effects.
Two renders from the same code and snapshot should produce the same document.
Do not read clocks, randomness, window, or other ambient platform state from
a view body. Put browser work in client-only effects such as
:platforms #{:client} or behind a declared host.
Create the frame, hydrate state, then adopt the DOM¶
The first client render must see the same state used by the server, and the
frame that state lands in must already exist. A hydrating boot therefore has
three ordered steps. They share the adapter precondition every browser boot
has — install one with rf/init! before the first frame, per
Installation.
(ns app.client
(:require [re-frame.core :as rf]
[re-frame.ssr :as ssr]
[re-frame.fresco :as h]
[app.views :as views]
[app.subs]
[app.events]))
(defonce app-root (h/client-root))
(defn ^:export run []
;; 1. Create the client frame both hydration steps name.
(rf/make-frame {:id :app/main :platform :client})
;; 2. Read __rf_payload and replace that frame's state.
(ssr/hydrate! {:frame :app/main})
;; 3. Adopt the existing server DOM.
(h/render! app-root
[h/frame-provider {:frame :app/main}
[views/page {}]]
(js/document.getElementById "app")
{:hydrate? true :identifier-prefix "main"}))
The three calls have different jobs:
rf/make-framecreates the frame. Neither hydration step does it for you:ssr/hydrate!seeds a frame that already exists, and the tree the adopting render takes SCOPEs that frame withh/frame-providerrather than ensuring it.h/frame-rootis the wrong verb here, and the reason is SHAPE — its ENSURE is commit-owned, so its first render emits no descendant subtree and the children arrive on a second pass. An adopting root must render the server's element shape on its FIRST pass,useIdpositions included, so aframe-rootwould hand React an empty tree where the server's markup is. (It would not overwrite the payload: re-ensuring a live frame preserves app-db and never replays:initial-events. The mismatch is the failure.) That is the whole reason the two verbs are two components.ssr/hydrate!applies the state payload through:rf/hydrate. It validates the wire frame id against the requested frame. A mismatch raises:rf.error/hydration-frame-id-mismatch; omitting:frameraises:rf.error/no-frame-context, because the target is supplied rather than inferred.h/render!with{:hydrate? true}calls React'shydrateRooton a container that already has server markup. It is a FIRST-CALL mode: every later render throughapp-rootupdates the root it adopted, and the key is ignored rather than hydrating twice.
Seeding an absent frame is silent — the DOM step is not
ssr/hydrate! installs the payload by dispatching :rf/hydrate, and a
dispatch into a frame that does not exist is a no-op, not an error. The
call still reads the payload and still returns it, so step 2 alone would
look like it worked. Step 3 is what catches it: h/frame-provider is
SCOPE-only and refuses an absent frame with
:rf.error/frame-provider-frame-absent, so a boot that skips step 1 fails
loudly at adoption instead of adopting the server's DOM against a frame that
never received the server's state.
The frame comes first, then state, then the DOM.
An adopting render has the same root lifecycle as a creating one: it is the
same h/render! through the same kind of handle, its opts carry React-root
options only, and h/unmount! takes the result down.
Important root rules:
- Hydration is root-scoped. Each root owns its container, identifier prefix, and recoverable-error stream.
- The call returns before adoption finishes. React hydrates asynchronously. The next line must not assume the DOM is fully client-owned.
:identifier-prefixmust match. React includes the prefix inuseIdoutput. A mismatch can flag every generated id in the root.- Controlled text is adopted, not rewritten later. The model value must already be present in the server bytes.
- Presence-managed children start as
:present. Existing page content does not replay an entry animation.
ssr/hydrate! returns the applied payload, or nil when the page carries no
payload. A shared boot path branches on that: nil means nobody
server-rendered this page, so h/render! WITHOUT :hydrate? builds a fresh
root under an [h/frame-root {:id … :initial-events …}] instead of adopting
one — the ENSURE
branch, where the frame and its seed are the tree's. The client-only branch
therefore needs no separate rf/make-frame at all; the SSR branch still does,
because the payload has to land in a configured frame before the DOM is
adopted.
Client-only components and fallbacks¶
Client-only is the default for foreign hosts:
(h/defhost stock-widget StockWidget)
(h/defhost trend-chart TrendChart
{:server :client-only
:fallback [:div
{:class "chart chart--pending"}
"Chart loads in the browser"]})
On the server, the crossing renders its fallback or nothing. The first client pass produces the same fallback. After adoption, the live component mounts. A fresh client-only application with no SSR mounts the live component directly and does not show the server fallback.
A fallback must be deterministic, inert Hiccup. It is inspected at declaration
time. A defview or defhost head inside it raises
:rf.error/fresco-host-fallback-boundary-head, because a later-running body
cannot be part of the fixed fallback contract.
Use a same-footprint skeleton to reduce layout shift.
One browser-only leaf inside a server-rendered region¶
The two policies compose, and the composition is the answer when a region is server-safe except for one leaf. There is no third policy to reach for: declare the region Render and the leaf Client-only, and only the leaf stands down.
(h/defhost product-panel ProductPanel
{:server :render})
(h/defhost viewport-badge ViewportBadge
{:server :client-only
:fallback [:span {:class "badge badge--pending"} "Measures in the browser"]})
[product-panel {}
[:p "Server-rendered copy."]
[viewport-badge {}]
[:p "More server-rendered copy."]]
The response carries the panel, both paragraphs and the badge's fallback; the badge's own component never runs on the server. The leaf is an ordinary Client-only crossing, so everything above applies to it unchanged — it hydrates against the fallback it emitted, and the live component mounts after adoption.
Nesting does not narrow what stands down. A Client-only crossing replaces itself and its children, so moving the region's server-safe content inside the leaf would delete that content from the response. Keep the leaf as small as the browser dependency actually is.
Render-safe hosts¶
Declare {:server :render} when a component is deterministic and safe to run
on the server:
Under Render, the real component is used for:
- server rendering;
- the first client hydration pass;
- fresh client mounts.
There is no component swap after adoption.
Render is also the only policy that sends a crossing's children to the server. A Client-only component renders its fallback or nothing instead of the whole crossing, including its children. A transparent wrapper such as a context provider therefore deletes its subtree from the server response unless it is declared Render.
A false Render assertion fails loudly, often as window is not defined during
the server render. Other declaration failures include:
:rf.error/fresco-host-bad-ssr-policyfor an unsupported policy or a:fallbackcombined with Render;:rf.error/fresco-bad-host-declarationfor an unknown host option.
Multiple roots report independently¶
A page can hydrate several roots against one frame and payload:
(h/defview help-panel [_]
[:aside
[:h2 "Need a hand?"]
;; Don't: deliberate server/client divergence.
[:p "Generated at " (js/Date.now)]])
(defonce app-root (h/client-root))
(defonce help-root (h/client-root))
(defn ^:export run []
(rf/make-frame {:id :app/main :platform :client})
(ssr/hydrate! {:frame :app/main})
;; TWO roots, so TWO handles — one handle owns at most one root.
(h/render! app-root
[h/frame-provider {:frame :app/main}
[views/page {}]]
(js/document.getElementById "app")
{:hydrate? true :identifier-prefix "main"})
(h/render! help-root
[h/frame-provider {:frame :app/main}
[views/help-panel {}]]
(js/document.getElementById "help")
{:hydrate? true :identifier-prefix "help"}))
The timestamp differs between server and client. React repairs the help root and reports a root-scoped mismatch:
The app root can still hydrate cleanly. Each adopting root has its own recoverable, caught, and uncaught error channels.
Xray associates hydration complaints with the root, view source, and host policy (Diagnostics).
React reports text differences and missing, extra, or wrong-type elements. Attribute-only divergence may produce only a development warning and can be harder to observe. The reliable fix is the same: values that both sides must share belong in the snapshot or hydration payload.
Coming from a Hiccup-tree hash
Some adapters can hash an authored Hiccup data tree before React sees it. Fresco views produce React elements and React performs the traversal, so there is no separate complete Hiccup tree to hash. Verification uses React's own root-scoped adoption reports.
When not to use SSR¶
A client-only application does not need the Node rendering service, payload
allowlist, or snapshot plumbing. Boot it with h/render! and no :hydrate?,
under an [h/frame-root {:id … :initial-events …}].
Applications behind a login wall often gain little from rendering private, per-user HTML on a server fleet.
Even in a client-only deployment, keep host and native server policies accurate. That makes later SSR adoption a configuration task rather than a view rewrite.
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
:rf.ssr/hydration-mismatch names a root and view |
Server and first client render differed, often because a body read a clock, random value, or browser global | Keep bodies deterministic; move platform work to client effects or host edges |
Every useId id in one root reports a mismatch |
The root's :identifier-prefix differs from the server prefix |
Use the same unique prefix in server/render and that root's adopting h/render! |
An adopting h/render! throws :rf.error/frame-provider-frame-absent |
The tree SCOPEs a frame nothing made — step 1 or step 2 was skipped | Create the frame and install the payload before adopting the DOM |
| Client-only widget shows a skeleton, then swaps to the live widget | The Client-only policy is working | Use a same-size fallback, or select Render only when the component is truly server-safe |
Declaration raises :rf.error/fresco-host-fallback-boundary-head |
The fallback contains a view or host head | Use plain deterministic Hiccup, or render the real component with {:server :render} |
Server render throws window is not defined under Render |
The component is not server-safe | Return it to Client-only and provide a fallback |
| A host's children are absent from server HTML | The host is Client-only, so the fallback replaces the whole crossing | Mark a server-safe transparent wrapper {:server :render} |
Declaration raises :rf.error/fresco-host-bad-ssr-policy |
Unsupported policy, or :fallback used with Render |
Use :render, or :client-only with an optional fallback |
Service boot raises :rf.error/ssr-missing-payload-policy |
No fail-closed payload policy was supplied | Allowlist every top-level app-db key the page reads, or explicitly select whole app-db |
| Pure views still mismatch | A rendered app-db key was omitted from the payload | Add the key to the allowlist |
Boot raises :rf.error/hydration-frame-id-mismatch |
Server :client-frame-id and client :frame differ |
Use one stable wire frame id on both sides |
Nothing throws, ssr/hydrate! returns a payload, and the page still renders empty |
The client frame did not exist yet, so the :rf/hydrate dispatch was a silent no-op |
rf/make-frame the client frame before ssr/hydrate! |
Advanced¶
Server policy by surface¶
React renders the server output; there is no parallel JVM string emitter.
| Surface | Server policy |
|---|---|
| Native Hiccup elements, fragments, and text | Render |
h/defview bodies and h/sub reads |
Render against the request snapshot |
| Controlled fields | Render their model value and checked attributes |
h/error-boundary |
The component renders, but a server throw uses React's server error channel rather than the client fallback |
Roots and h/as-component |
Render, with request isolation and prefix matching |
h/defhost, slots, render props, and h/as-element |
Client-only until the declaration selects Render |
| Portals, raw React elements, and opaque foreign components | Client-only |
A React element returned directly from a defview |
Render, as React renders it; a component inside it has no Fresco gate, so it must be server-safe itself |
React islands, through h/defhost |
Client-only until the declaration selects Render |
| Resource boundaries | Follow their module's server contract; a passive read causes nothing, so no client [:rf.resource/ensure …] runs during server rendering |
Event intents require no wire serialisation. Each side turns the same vector into its own callback.
Context providers¶
A server-safe context provider must be declared Render so its children remain in the response:
(def theme-context
(react/createContext "light"))
(h/defhost theme-provider
(.-Provider theme-context)
{:server :render})
A provider whose value depends on browser-only state has no deterministic server contract and remains Client-only, along with its subtree.
Islands under SSR¶
An island is Client-only unless its host declares Render:
(defn ticker [^js props]
(let [price (n/use-sub [:quote/price (.-symbol props)])]
(react/createElement "span" #js {:className "ticker"} price)))
(h/defhost ticker-host ticker
{:server :render})
During server rendering, n/use-sub performs the same cold snapshot read as
h/sub. It does not install a live subscription.
Ring-hosted: rendering on a Node sidecar¶
Everything above renders in a Node process you drive yourself. There is also a supported path where a JVM Ring host owns the request and calls out to a Node sidecar for the body markup — which is what you want when the rest of the application already runs on the JVM.
The division is strict. The JVM keeps the request frame, the boot-event drain,
the blocking-resource settle, the <head>, __rf_payload, the shell, the
status, headers, cookies, redirects, error projection and frame teardown. Node
returns a string and nothing else, so the sidecar's own HTTP status never
reaches the browser and no partial page is possible.
Three pieces make it work, and only the middle one is new to this chapter:
re-frame.fresco.server/render-body— the body-only sibling ofserver/render. It takes:hiccup, a:render-stateenvelope and an:identifier-prefix, installs both state partitions into a fresh per-request frame in one write, and returns inner markup. It builds no payload, no document and no head, because the JVM already owns all three. It replays no boot events either — the JVM drained them, and the projection is the settled result.- A render module in a
:node-librarybuild, publishing a build id and an entry table whose per-entry allowlists are the render-visibility policy. :rendereron the Ring handler, pointing atre-frame.ssr.ring.node/renderer.
The :identifier-prefix rule from
Create the frame, hydrate state, then adopt the DOM
applies unchanged and matters more here, because two processes now have to
agree on the string rather than one.
The full recipe — both builds, the module, the two state policies and why they
differ, the serve command, build-id skew and the deployment posture — is
Render on Node. The worked example is
substrates/fresco/login, whose
server.cljs is a real render module driven by the test suite against the real
views; its host.clj is annotated wiring rather than a server you can start,
because that example's model is ClojureScript-only today.
Whichever host you use, the service renders whole pages. Streaming, React Server Components, islands, and no-JavaScript progressive enhancement are outside this product's scope.