Tutorial: render on the server¶
This tutorial builds one small articles app and takes it from a string of HTML at a JVM REPL to a Ring server whose pages the browser hydrates. Each step adds one piece of the server/client lifecycle; Step 7 swaps the hand-written pieces for the production adapter.
The app is adapted from examples/capabilities/ssr/ssr/.
Steps 1–3 run at a JVM REPL, Steps 4–5 in the browser, and Steps 7–8 on a Jetty
server.
Step 0 — turn SSR on¶
SSR ships in its own artefact, day8/re-frame2-ssr, so an app that never renders
server-side carries none of it. During alpha no re-frame2 artefact is published, so
they resolve with :local/root from a re-frame2 checkout cloned beside your project:
;; deps.edn
{:paths ["src" "resources"]
:deps
{day8/re-frame2 {:local/root "../re-frame2/implementation/core"}
day8/re-frame2-ssr {:local/root "../re-frame2/implementation/ssr"}}}
The app is one file, src/app/core.cljc, compiled twice: by Clojure on the JVM for
the server and by shadow-cljs for the browser. Reader conditionals (#?(:clj …),
#?(:cljs …)) mark the few lines that belong to one side.
(ns app.core
(:require [re-frame.core :as rf]
[re-frame.ssr :as ssr])) ;; render-to-string, hydrate!, the :rf.server/* fx
Requiring re-frame.ssr is what installs SSR. Without it, the first rf/reg-head or
rf/reg-error-projector call throws :rf.error/ssr-artefact-missing.
Step 1 — render a view to a string¶
A view returns hiccup, which is plain data, and
ssr/render-to-string turns that data into an HTML string. It needs no browser,
DOM or React, so you can try it at a REPL before any HTTP exists.
Register an event, a subscription and a root view:
;; cf. examples/capabilities/ssr/ssr/core.cljc (condensed)
(rf/reg-event :articles/seed
(fn [{:keys [db]} [_ articles]]
{:db (assoc db :articles articles)}))
(rf/reg-sub :articles/slice (fn [db _] (:articles db)))
(rf/reg-view ^{:rf/id :app/root} root-view []
(let [arts @(subscribe [:articles/slice])]
[:main.page
[:h1 "Recent articles"]
(if (seq arts)
(into [:ul] (for [{:keys [id title]} arts]
^{:key id} [:li [:h3 title]]))
[:p "No articles."])]))
Install the headless SSR adapter, create a frame, seed it and render:
(rf/init! ssr/adapter) ;; the server-side substrate; no React needed
(rf/with-new-frame [f (rf/make-frame {})]
(rf/dispatch-sync [:articles/seed [{:id "1" :title "Hello, server"}]] {:frame f})
(ssr/render-to-string ((rf/view :app/root)) {}))
;; => "<main class=\"page\" …><h1>Recent articles</h1><ul><li><h3>Hello, server</h3></li></ul></main>"
;; (a development build also stamps data-rf-view and source-coordinate attributes where the … sits)
What you see: HTML from your own view, on a JVM with no browser.
(rf/view :app/root) looks up the view, and calling it returns the hiccup it renders.
The call runs under the frame with-new-frame binds. render-to-string also accepts
the uncalled vector form [(rf/view :app/root)]; this tutorial calls the view
throughout because Step 3 hashes the tree, and the hash needs the called form.
render-to-string lives only on re-frame.ssr; there is no rf/ facade copy.
None of the registrations changed to make this work: the handler, the subscription and the view were already pure.
render-to-string runs in a browser too, so this cell is Step 1, live. It runs under
this page's own frame and renderer, so it leaves out init! and with-new-frame.
Cells don't support the ^{:rf/id …} override, so the view takes the id its namespace
gives it and the cell calls it through its Var. Edit a title or the view and press
Mod-Enter:
(require '[re-frame.core :as rf]
'[re-frame.ssr :as ssr])
(rf/reg-event :articles/seed
(fn [{:keys [db]} [_ articles]]
{:db (assoc db :articles articles)}))
(rf/reg-sub :articles/slice (fn [db _] (:articles db)))
(rf/reg-view root-view []
(let [arts @(subscribe [:articles/slice])]
[:main.page
[:h1 "Recent articles"]
(if (seq arts)
(into [:ul] (for [{:keys [id title]} arts]
^{:key id} [:li [:h3 title]]))
[:p "No articles."])]))
(rf/dispatch-sync [:articles/seed [{:id "1" :title "Hello, server"}]])
[:pre {:style {:white-space "pre-wrap"}} (ssr/render-to-string (root-view) {})]
Why is subscribe unqualified in the view?
reg-view injects subscribe and dispatch into the view body, already bound to
the frame the view renders under. On the JVM a deref reads the current value; in
the browser the same deref also registers a reaction so the view re-renders on
change.
Step 2 — a frame per request¶
Concurrent requests must not see each other's state. Give each request its own frame: an isolated app-db, created when the request arrives and destroyed when the response is written.
with-new-frame from Step 1 destroys its frame when the block exits. The handler
below runs the same lifecycle long-hand, with make-frame and a destroy-frame! in a
finally, because the frame id is needed before the frame exists: the request is
stored against the id, and the frame's boot event reads it from there.
The boot event is :rf/server-init. The framework reserves the name; you supply the
body. It reads the request through a declared coeffect:
;; cf. examples/capabilities/ssr/ssr/core.cljc
(def sample-articles ;; stand-in for a real data source
[{:id "1" :title "Hello, server"}
{:id "2" :title "Hydration, verified"}])
(rf/reg-event :rf/server-init
{:platforms #{:server} ;; records intent; Step 6 explains
:rf.cofx/requires [:rf.server/request]} ;; hand me the request, as data
(fn [{:keys [db rf.server/request]} _]
(let [limit (or (some-> (get-in request [:query-params "limit"]) parse-long) 10)]
{:db (assoc db :articles/limit limit)
:fx [[:dispatch [:articles/seed (vec (take limit sample-articles))]]]})))
:rf.server/request is the request map exactly as the host stored it. For Ring that
is :uri, :request-method, :headers, plus whatever middleware adds:
:query-params from wrap-params, :cookies from wrap-cookies, :session from
wrap-session. A real page usually starts its HTTP fetches here instead of reading a
def. The worked example fires :rf.http/managed here and,
because the reply lands asynchronously, its handle-request waits for it by hand
before rendering; Step 7 shows how the Ring adapter waits instead.
The per-request lifecycle:
;; cf. examples/capabilities/ssr/ssr/core.cljc — handle-request, condensed
#?(:clj
(defn handle-request [request]
(let [fid (keyword "rf.frame" (str (gensym "f")))]
(ssr/set-request! fid request) ;; 1. store the request against the id
(rf/make-frame ;; 2. fresh frame; :initial-events run
{:id fid ;; synchronously as it comes up,
:platform :server ;; so the request must be stored first
:initial-events [[:rf/server-init]]})
(try
(rf/with-frame fid
(let [hiccup ((rf/view :app/root))] ;; 3. the settled state, as hiccup
(ssr/render-to-string hiccup {}))) ;; 4. hiccup → HTML string
(finally
(rf/destroy-frame! fid)))))) ;; 5. torn down on every exit path
A handler is a function, so you can serve a request at the REPL:
(handle-request {:request-method :get :uri "/" :query-params {"limit" "1"}})
;; => "<main class=\"page\" …><h1>Recent articles</h1><ul><li><h3>Hello, server</h3></li></ul></main>"
make-frame runs :initial-events synchronously and the runtime drains: it keeps
processing events, and the events those dispatch, until the queue is empty. By the
time the view renders, app-db holds the finished state. The finally matters on a
long-running server: without it, every request that throws leaks a frame.
destroy-frame! also clears the stored request.
Step 3 — ship the state with the HTML¶
When the browser's JavaScript boots, it needs the state the server finished in, or it would re-fetch everything. So the server ships the HTML and a serialized payload of that state in the same page.
Add two JVM-only requires:
(ns app.core
(:require [re-frame.core :as rf]
[re-frame.ssr :as ssr]
#?(:clj [re-frame.ssr.html-helpers :as html]) ;; the escaper
#?(:clj [re-frame.ssr.payload-policy :as payload-policy]))) ;; the payload builder
Then replace the rf/with-frame block in handle-request with this one, which
returns a Ring response:
;; cf. examples/capabilities/ssr/ssr/core.cljc — the render + payload half
(rf/with-frame fid
(let [hiccup ((rf/view :app/root))
rhash (ssr/render-tree-hash hiccup) ;; hash once …
page (ssr/render-to-string hiccup {:render-hash rhash}) ;; … stamp it here …
payload (payload-policy/build-payload ;; stamps :rf/version from the SSR-owned constant
nil ;; no :rf/frame-id; the client names its own frame
(rf/app-db-value fid) ;; your state (all of it; see below)
rhash ;; … and again here, for Step 5's check
{:runtime-db (payload-policy/project-runtime-db ;; the framework's (route, machines),
(:rf.db/runtime (rf/frame-state-value fid)) fid)})] ;; projected, never raw
{:status 200
:headers {"Content-Type" "text/html"}
:body (str "<!DOCTYPE html><html><head><meta charset='utf-8'/></head><body>"
"<div id='app'>" page "</div>"
"<script id='__rf_payload' type='application/edn'>"
(html/escape-edn-script-body (pr-str payload))
"</script>"
"<script src='/main.js'></script>"
"</body></html>")}))
(-> (handle-request {:request-method :get :uri "/" :query-params {"limit" "1"}})
(select-keys [:status :headers]))
;; => {:status 200, :headers {"Content-Type" "text/html"}}
;; (:body …) is the whole document: rendered HTML and the payload script.
- The payload is EDN in a
<script>tag with the fixed id__rf_payload, which is how the client finds it in Step 4. - It goes through an escaper. If an article contained the text
</script>, writing it raw would close the script element early.escape-edn-script-bodyrewrites<inside EDN strings, so the payload still reads back byte-for-byte. :rf/render-hashis a structural fingerprint of the render tree. You compute it once withrender-tree-hashand use it twice::render-hashstamps it on the root element as adata-rf-render-hashattribute, and the payload carries the same string, so the tree is walked only once. The hash covers only the root view's own markup, not the views it nests, which is why this root view returns its markup directly (what the hash covers).
The hash and the stamp need no JVM either. This cell hashes Step 1's view, which the cell above registered, and stamps the hash on the root element. Seed a second article and both the markup and the hash change:
(require '[re-frame.core :as rf]
'[re-frame.ssr :as ssr])
(def two-articles
[{:id "1" :title "Hello, server"}
{:id "2" :title "Hydration, verified"}])
(defn stamped-page []
(let [hiccup (root-view)
rhash (ssr/render-tree-hash hiccup)]
[:div
[:button {:on-click #(rf/dispatch [:articles/seed two-articles])}
"Seed a second article"]
[:pre {:style {:white-space "pre-wrap"}}
(ssr/render-to-string hiccup {:render-hash rhash})]]))
[stamped-page]
This handler writes its own <!DOCTYPE html> envelope around a fragment. When your
root view renders the whole [:html …] document instead, pass :doctype? true to
render-to-string and it prefixes the doctype itself.
This version ships the whole app-db, which leaks any secret the app puts in state.
Step 7's adapter makes you declare an allowlist instead; see
the fail-closed allowlist. The
runtime-db half is never shipped raw, even here: project-runtime-db keeps only the
durable route and machine slices, redacts any value the app classified :sensitive
(such as a reset token in a query string), and leaves out the frame's classification
registry. On this page the runtime-db is empty, so the projection is nil and
build-payload leaves :rf/runtime-db out. A present nil would be a malformed
slice that hydration rejects.
Step 4 — hydrate on the client¶
The browser now has painted HTML and a payload. The client adopts both: it installs the state and attaches to the existing DOM instead of re-rendering from scratch.
The client needs the Reagent adapter, React and a shadow-cljs build. Add the adapter
and a build alias to deps.edn:
;; deps.edn
{:paths ["src" "resources"]
:deps
{day8/re-frame2 {:local/root "../re-frame2/implementation/core"}
day8/re-frame2-ssr {:local/root "../re-frame2/implementation/ssr"}
day8/re-frame2-reagent {:local/root "../re-frame2/implementation/adapters/reagent"}} ;; new
:aliases
{:shadow {:extra-deps {thheller/shadow-cljs {:mvn/version "3.4.10"}}}}} ;; new
Add a package.json for React and shadow-cljs, and a shadow-cljs.edn whose build
writes resources/public/main.js, the file the page's <script src='/main.js'>
loads:
{"dependencies": {"react": "19.3.0", "react-dom": "19.3.0"},
"devDependencies": {"shadow-cljs": "3.4.10"}}
;; shadow-cljs.edn
{:deps {:aliases [:shadow]}
:builds {:app {:target :browser
:output-dir "resources/public"
:asset-path "/"
:modules {:main {:init-fn app.core/run}}}}}
Add the adapter to the ns form as a ClojureScript-only require,
#?(:cljs [re-frame.adapter.reagent :as reagent-adapter]), then write the client
entry point:
;; cf. examples/capabilities/ssr/ssr/core.cljc — the client entry point
(rf/reg-event :app/client-bootstrap ;; seeds a page nobody server-rendered
(fn [{:keys [db]} _]
{:db (assoc db :articles [])}))
#?(:cljs (defonce app-root (reagent-adapter/client-root)))
#?(:cljs
(defn run []
(rf/init! reagent-adapter/adapter) ;; the browser substrate this time
(rf/make-frame {:id :app :platform :client}) ;; the client's own, named frame
(let [el (js/document.getElementById "app")
payload (ssr/hydrate! {:frame :app
:render-tree-fn (fn [] ((rf/view :app/root)))})
tree [rf/frame-provider {:frame :app} [(rf/view :app/root)]]]
(when-not payload
;; nil: nobody server-rendered this page, so seed before the first render
(rf/dispatch-sync [:app/client-bootstrap] {:frame :app}))
;; payload: adopt the painted DOM; nil: mount a fresh root
(reagent-adapter/render! app-root tree el {:hydrate? (some? payload)}))))
hydrate! does three things in a fixed order. It reads the __rf_payload
script, hydrates by dispatching [:rf/hydrate payload] before the first render
(installing the server's app-db and its runtime-db slice in one step), and
verifies the render hash (Step 5). It returns the payload it applied, or nil
when there was none, which is how run knows whether the page was server-rendered.
hydrate! handles state only; it never touches the DOM. Adopting the server's markup
is the separate reagent-adapter/render! call with {:hydrate? true} (React's
hydrateRoot underneath). Without that option the same call discards the markup and
mounts fresh, which is right only for the client-only branch.
Three details in that code:
- The same frame id goes to
hydrate!and toframe-provider. Pass no:frameandhydrate!raises:rf.error/no-frame-context; the runtime never picks a frame for you. The name:appis yours. (The example file names its client frame:rf/default, which is also just a name.) :render-tree-fncalls the view, because the verify step must hash the same tree shape the server hashed in Step 3. The vector form would hash differently even though nothing on the page differs.- Hydration replaces the client's state; it does not merge. Whatever the client
pre-seeded is overwritten. The framework provides the
:rf/hydratehandler, and it rejects a malformed payload wholesale, leaving existing state untouched. State only the client has, such as a restored draft, is seeded after hydration: dispatch your own event oncehydrate!returns (concepts).
Compile the client with npx shadow-cljs watch app; Step 7 serves the page and
main.js together. To try hydration before that, the worked example ships a
hand-authored index.html, a frozen snapshot of what its handle-request serves,
and its run hydrates it the same way.
What you see: the page is painted before your JavaScript loads. When run
finishes nothing flashes: the client's first render matches the HTML, the articles
are in app-db without a re-fetch, and events dispatch as in any re-frame2 app.
Step 5 — break it on purpose¶
A hydration mismatch is a client first render that disagrees with the server's HTML, usually from something like a date rendered in two timezones or state the server set that the client never read.
Steps 3 and 4 already wired the check: the server ships :rf/render-hash, and
hydrate! hashes the tree from :render-tree-fn and compares. To trip it, put
something non-deterministic in the root view:
;; Don't do this: it exists to trip the check. It replaces Step 1's root view in app.core.
(rf/reg-view ^{:rf/id :app/root} root-view []
(let [arts @(subscribe [:articles/slice])]
[:main.page
[:p (str "Rendered at " #?(:clj (System/currentTimeMillis) :cljs (js/Date.now)))]
[:h1 "Recent articles"]
(if (seq arts)
(into [:ul] (for [{:keys [id title]} arts]
^{:key id} [:li [:h3 title]]))
[:p "No articles."])]))
The timestamp sits in the root view's own markup, so it is part of the hashed tree: the server hashes one time, the client's first render another.
What you see: the page still works, because the default recovery is to warn and render the client's view. The trace stream carries a structured error:
{:operation :rf.ssr/hydration-mismatch
:op-type :error
:recovery :warned-and-replaced ;; what the runtime did about it
:tags {:server-hash "a3f29c01" ;; the tree the server shipped…
:client-hash "0b77e4d2" ;; …vs the client's first render
:frame :app
:failing-id :rf/hydrate
,,,}}
The trace does not say which node diverged (why), so look for the non-determinism: clocks, locales, unordered collections.
For CI, create the client frame with :ssr {:on-mismatch :hard-error} and a mismatch
throws a structured exception, so it fails the build. The real fix is to put the
timestamp in app-db at init: it rides the payload and both sides render the same
value.
Step 6 — gate the one-sided code: :platforms¶
Some work is meaningless on one side. The JVM has no localStorage; the browser has
no request coeffect. Instead of branching in handler bodies, declare where a
capability may run:
;; cf. examples/capabilities/ssr/ssr/core.cljc
(rf/reg-fx :auth.session/store
{:doc "Persist a session token in localStorage."
:platforms #{:client}} ;; the server render skips this, with a trace
(fn [_ {:keys [token]}]
#?(:cljs (.setItem js/localStorage "auth/token" token))))
When a server-side drain reaches a #{:client} effect, it skips it and emits a
:rf.fx/skipped-on-platform trace. The handler that returned the effect does not
need to know which runtime it is on. Coeffects are gated the same way:
:rf.server/request is #{:server}, so a handler running client-side after
hydration does not receive it. Events are not gated: an event registration stores
:platforms, but the router never reads it. Step 2's :rf/server-init stays on
the server because only the per-request frame's :initial-events dispatch it.
Step 7 — swap in the Ring adapter¶
Steps 2–3 built the lifecycle by hand: store the request, create the frame, drain,
render, build the payload, respond, tear down. day8/re-frame2-ssr-ring packages
that sequence as one handler constructor. Add it and Jetty to deps.edn's :deps:
day8/re-frame2-ssr-ring {:local/root "../re-frame2/implementation/ssr-ring"}
ring/ring-jetty-adapter {:mvn/version "1.12.1"}
The server goes in its own JVM-only namespace, which loads app.core's registrations:
;; src/app/server.clj
(ns app.server
(:require [app.core]
[re-frame.core :as rf]
[re-frame.ssr :as ssr]
[re-frame.ssr.ring :as ssr.ring]
[ring.adapter.jetty :as jetty]
[ring.middleware.params :refer [wrap-params]]
[ring.middleware.resource :refer [wrap-resource]]))
(rf/init! ssr/adapter) ;; once per JVM, as in Step 1
(def handler
(ssr.ring/ssr-handler
{:initial-events [[:rf/server-init]] ;; Step 2's boot event
:root-view (fn [] ((rf/view :app/root))) ;; Step 2's called render target, hashed
:payload [:articles]})) ;; Step 3's payload, now an allowlist
(def app
(-> handler
(wrap-resource "public") ;; /main.js from resources/public (Step 4's build)
wrap-params)) ;; fills :query-params, which Step 2 reads
(jetty/run-jetty app {:port 3000 :join? false})
Ordinary Ring middleware supplies the rest: wrap-resource serves /main.js, which
the handler's page shell loads by default, and wrap-params parses ?limit=1 into
the :query-params that :rf/server-init reads.
:root-view is a fn that calls the root view, as in Step 2, so the handler can hash
the page. The vector form [(rf/view :app/root)] renders the same HTML, but the
handler ships no hash for it and Step 5's check never runs
(why).
:payload is required, and it is an allowlist. Name the top-level app-db keys
that may ship; every other key stays on the server, including keys added later. Omit
:payload and construction throws :rf.error/ssr-missing-payload-policy at boot. To
ship everything, say so explicitly with :rf.ssr.payload/whole-app-db.
What you see: curl localhost:3000 returns the full document (rendered HTML, the
__rf_payload script, the hash on the root element) and the client from Step 4
hydrates it unchanged.
The handler renders once the boot events' synchronous work settles. It does not wait
for an :rf.http/managed fetch those events start, so a page that loads its data over
HTTP declares it as a route resource with :blocking? true, which the handler does
wait for (Data the first render needs).
Step 8 — shape the response¶
The Ring handler builds the status, headers and cookies from effects your server-side
handlers return. Add a cache header to the boot event from Step 2 (the new line is
the last :fx entry):
(rf/reg-event :rf/server-init
{:platforms #{:server}
:rf.cofx/requires [:rf.server/request]}
(fn [{:keys [db rf.server/request]} _]
(let [limit (or (some-> (get-in request [:query-params "limit"]) parse-long) 10)]
{:db (assoc db :articles/limit limit)
:fx [[:dispatch [:articles/seed (vec (take limit sample-articles))]]
[:rf.server/set-header {:name "Cache-Control" :value "public, max-age=60"}]]})))
What you see: curl -i localhost:3000 now shows Cache-Control: public, max-age=60
beside the page. :rf.server/set-status, cookies and redirects work the same way;
every :rf.server/* effect is server-only.
Controlling the response lists them all. The hand-rolled handler from
Step 3 ignores these effects, because it writes its own response map.
The complete shape¶
| Half | Surface | You supply |
|---|---|---|
| Server | ssr.ring/ssr-handler |
:initial-events, :root-view that calls the root view, a :payload allowlist; :rf.server/* effects for status, headers and cookies |
| Client | ssr/hydrate! then reagent-adapter/render! with {:hydrate? (some? payload)} |
The same :frame as frame-provider; :render-tree-fn that calls the root view |
The hand-rolled lifecycle from Steps 2–3 is what the adapter runs, which makes it the model to reason with when something misbehaves. Both halves in one listing: A complete loop.
Advanced¶
Native UIx — adopt through the shared render path¶
Reagent views return a data render tree, so the server and client each hash it. A
native UIx app, whose views compile straight to React elements, has no such tree and
is verified by React adoption instead: on the client, call hydrate! without
:render-tree-fn (which substrates hash).
Hydrate the DOM through re-frame2's client mount entry,
(re-frame.substrate.adapter/render tree el {:hydrate? true}) (the adapter's
:render slot), and not through uix.dom/hydrate-root or react-dom hydrateRoot
directly. Only that path installs the framework onRecoverableError reporter,
bounded to the adoption window and composed over any :on-recoverable-error you
pass, so a recoverable mismatch emits the same :rf.ssr/hydration-mismatch trace,
tagged :where re-frame.substrate.spine/make-render. Hydrating with the
substrate's own renderer bypasses it: React still recovers the DOM, but with no
framework trace.
Adoption reports only what React itself recovers from. An attribute-only mismatch (a
stale class, style or ARIA value on an element whose tag and text match) is not in
that set: React warns in development, makes
no promise to patch it,
and calls neither onRecoverableError nor any production equivalent. So a divergent
attribute hydrates silently on this tier, with no trace. The hiccup tier catches it
because it keeps a client render tree it can hash.
Fresco verifies by adoption for the same reason; see Fresco → SSR and hydration.