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)] and renders the same HTML for it.
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, where the frame's handlers can read it.
The boot event is an ordinary event your app names and registers; this tutorial
calls it :app/server-init. The page size comes from the request's ?limit=. The
handler keeps it in app-db, so it arrives on the event's payload, where the dispatch
records it:
;; 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"}])
(defn request-limit [request] ;; the request fact the page keeps
(or (some-> (get-in request [:query-params "limit"]) parse-long) 10))
(rf/reg-event :app/server-init
{:platforms #{:server}} ;; records intent; Step 6 explains
(fn [{:keys [db]} [_ {:keys [limit]}]]
{:db (assoc db :articles/limit limit)
:fx [[:dispatch [:articles/seed (vec (take limit sample-articles))]]]}))
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.
A handler can also read the request itself, through a declared
coeffect: :rf.cofx/requires [:rf.server/request].
: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. Use it for decisions, such as a redirect; a value that lands in
app-db rides the payload, as limit does here
(why).
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 [[:app/server-init {:limit (request-limit request)}]]})
(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 a JVM-only require for the escaper:
(ns app.core
(:require [re-frame.core :as rf]
[re-frame.ssr :as ssr]
#?(:clj [re-frame.ssr.html-helpers :as html]))) ;; the escaper
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 [page (ssr/render-to-string ((rf/view :app/root)))
payload (ssr/hydration-payload fid {:payload [:articles]})] ;; the state the client may see
{: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.
Rendering needs no JVM either. This cell renders Step 1's view, which the cell above registered. Seed a second article and the markup changes:
(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 rendered-page []
[:div
[:button {:on-click #(rf/dispatch [:articles/seed two-articles])}
"Seed a second article"]
[:pre {:style {:white-space "pre-wrap"}}
(ssr/render-to-string (root-view))]])
[rendered-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.
hydration-payload builds the payload from the frame's finished state, and ships
neither partition raw:
:payloadis an allowlist of the top-level app-db keys the client may see.:articles/limitstays on the server, and so does any key added later. Leave:payloadout and the call throws:rf.error/ssr-missing-payload-policy; see the fail-closed allowlist.- Classified values are redacted. A path the app classified
:sensitiveinside an allowed key ships redacted. - The runtime-db keeps only durable state: the route and machine slices, with
classified values (such as a reset token in a query string) redacted. On this page
the runtime-db is empty, so the
payload has no
:rf/runtime-dbat all. - There is no
:rf/frame-id. The client names its own frame in Step 4, so the server's per-request frame id stays off the wire.
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 :rf/default :platform :client}) ;; the client's own, named frame
(let [el (js/document.getElementById "app")
payload (ssr/hydrate! {:frame :rf/default})
tree [rf/frame-provider {:frame :rf/default} [(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 :rf/default}))
;; payload: adopt the painted DOM; nil: mount a fresh root
(reagent-adapter/render! app-root tree el {:hydrate? (some? payload)}))))
hydrate! does two things in a fixed order. It reads the __rf_payload
script, then hydrates by dispatching [:rf/hydrate payload] before the first
render (installing the server's app-db and its runtime-db slice in one step). 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), and that root reports any mismatch React recovers from
(Step 5). Without that option the same call discards the markup and mounts fresh,
which is right only for the client-only branch.
Two 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 id is yours to choose; this tutorial and its example file use:rf/default, the conventional id for an app's single frame. - 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.
Step 4 already wired the report: the adapter's hydrating render! installs a
reporter on the root, and React reports each part of the server's markup it has to
re-render. 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 server renders one time and the client's first render another, so the text of
that <p> differs and React re-renders it on the client.
What you see: the page still works, because React recovers by rendering the client's view. The trace stream carries a structured warning:
{:operation :rf.ssr/hydration-mismatch
:op-type :warning
:recovery :warned-and-replaced ;; React has already re-rendered it
:tags {:error "Hydration failed because …" ;; React's own message
:where re-frame.substrate.spine/make-render}}
The report carries React's message rather than a path to the node (why), so look for the non-determinism: clocks, locales, unordered collections.
For CI, pass render! an :on-recoverable-error that records each error, and fail
the run when one arrives (Testing).
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/core/login/model.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 :app/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
{:frame {:initial-events ;; Step 2's boot event, built per request
(fn [request]
[[:app/server-init {:limit (app.core/request-limit request)}]])}
:root-view (fn [] ((rf/view :app/root))) ;; Step 2's render target
:payload [:articles]})) ;; Step 3's 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 request-limit reads.
:root-view is a fn that calls the root view, as in Step 2. The vector form
[(rf/view :app/root)] renders the same HTML and works as well.
: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) 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 :app/server-init
{:platforms #{:server}}
(fn [{:keys [db]} [_ {:keys [limit]}]]
{: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 |
a :frame config with its :initial-events, :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 |
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¶
A native UIx app, whose views compile straight to React elements, hydrates the way
the Reagent app above does: hydrate! installs the state, and the adapter's
hydrating render reports what React recovers from
(which root reports it).
Hydrate the DOM through the adapter — the first uix-adapter/render! through a client
root with {:hydrate? true}, or (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 the adapter's mount
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, with no trace, on every substrate
(what is not reported).
Fresco reports the same way; see Fresco → SSR and hydration.