Skip to content

The model

Server-side rendering in re-frame2 runs your app's events, subscriptions and views on the JVM, once per request, in a frame created for that request. The server ships the HTML together with the state it rendered from, and the browser installs that state and adopts the HTML instead of rendering from scratch. This page explains how those parts fit together; the tutorial builds them step by step.

Status codes, headers and redirects are on Control the response, and <title> and social metadata on Head metadata.

For JavaScript developers

Coming from Next.js or Remix? You keep first-paint HTML, loaders and form actions, without a separate server layer. A "loader" is ordinary events in a per-request frame. Full map: Coming from Next.js.

Optional artefact

Require re-frame.ssr once at boot (Maven day8/re-frame2-ssr; Ring adapter day8/re-frame2-ssr-ring). Forget the require and the first rf/reg-head or rf/reg-error-projector call throws :rf.error/ssr-artefact-missing.

Why the same code runs on a JVM

A render uses three kinds of code, and none of them touches the browser:

  • Event handlers are pure. An event is a fact; its handler is (coeffects, event) → effect map. No window, no lifecycle, so a JVM runs it.
  • Subscriptions are pure derivations. State in, value out — nodes on the derivation graph, rooted at app-db.
  • The render tree is data. Hiccup is nested vectors and maps, and render-to-string is a pure function from hiccup to an HTML string. It needs no React, no DOM and no JS runtime.

render-to-string runs in the browser too, so you can see what the server sends for a piece of hiccup. Edit it and press Mod-Enter:

(require '[re-frame.ssr :as ssr])

[:pre {:style {:white-space "pre-wrap"}}
 (ssr/render-to-string
   [:article#intro.card                          ;; id and class attributes
    [:h2 "Why <head> tags & JSON-LD matter"]     ;; text is escaped
    [:img {:src "/img/intro.png" :alt "Intro"}]  ;; void: no closing tag
    ;; a true attribute is written bare, a false one left out
    [:input {:type "checkbox" :checked true :disabled false}]])]

So every handler, subscription and view you have already written can run on the server. Work that needs the browser — a localStorage write, a focus trap — is an effect you declare as client-only, rather than a branch in your code (:platforms, below).

For JavaScript developers

In a typical Next.js codebase the same component renders on both sides, and you guard it with typeof window === 'undefined' because the component itself touches the browser. Here your components never ask that question.

A request, start to finish

The host adapter runs this whole sequence for you. Every section below refines one step of it:

sequenceDiagram
    participant B as Browser
    participant H as Host adapter (Ring)
    participant F as Per-request frame (JVM)
    B->>H: GET /articles/intro
    H->>F: create frame · stash the request
    F->>F: :initial-events fire — drain, then wait for blocking resources
    F->>H: render-to-string → HTML + state payload
    H->>B: HTML + <script id="__rf_payload">
    H->>F: destroy the frame (finally)
    B->>B: JS boots → hydrate! installs the payload → render! adopts the DOM
  1. An HTTP request arrives. The host adapter creates a frame for this request and stashes the request map where handlers can read it.
  2. The frame's :initial-events fire in order: read the session, set the route. Entering the route starts its resource loads.
  3. The runtime drains: it processes events, and every event those events dispatch, until the queue settles (the run-to-completion guarantee). Then it waits for the route's blocking resources to load. That is the only wait, so data the first render needs comes from a blocking resource.
  4. The root view renders to hiccup, and render-to-string turns it into HTML.
  5. The server sends the HTML plus a serialised state payload.
  6. The client boots. ssr/hydrate! installs the payload (it dispatches :rf/hydrate) before the first render, then the substrate hydrates the existing DOM (React's hydrateRoot, via the adapter). Because the first render matches the server's HTML, React adopts the DOM instead of replacing it.
  7. The per-request frame is destroyed, in a finally, on every exit path.

Steps 2–4 run the handlers, subscriptions and views you already wrote. The per-request frame is an ordinary frame, exactly as in Frames: isolated worlds, so a hundred concurrent requests are a hundred separate app-dbs that cannot see or corrupt one another.

The simplest server

Wire the Ring adapter once. It runs the whole lifecycle above — frame create, drain, render, payload, response, teardown:

(require '[ring.adapter.jetty :as jetty]
         '[re-frame.core      :as rf]
         '[re-frame.ssr       :as ssr]
         '[re-frame.ssr.ring  :as ssr-ring])

(rf/init! ssr/adapter)                             ;; once, at boot: the server-side render adapter

(def handler
  (ssr-ring/ssr-handler
    {:frame     {:initial-events [[:app/server-init]]}
     :root-view (fn [] ((rf/view :app/root)))
     :payload   [:articles :session-user]}))        ;; allowlist of app-db keys to ship

(jetty/run-jetty handler {:port 3000 :join? false})

Three options do the work:

  • :frame — the per-request frame's config, the map make-frame takes, so the config your client boot mounts with serves here too. Its :initial-events are the per-request setup (step 2): a vector of events, or a (fn [request] → initial-events-vector) when the setup depends on the Ring request. The handler gives each request frame its own :id and :platform :server, so those two stay out of :frame.
  • :root-view — what the adapter renders once the frame settles (step 4): a 0-arity fn returning hiccup, as shown, or the hiccup itself, [(rf/view :app/root)]. Both render the same HTML. The head of the tree must be a callable view reference — the Var rf/reg-view defines, or (rf/view :id). A bare keyword head is an HTML element.
  • :payload — the allowlist of top-level app-db keys to send to the client (step 5). It is a security boundary, covered below.

That is a working SSR server. The other options have defaults you rarely change; they are summarised under Other handler options, and the ssr-handler reference lists every option with its errors. The tutorial builds the same lifecycle by hand first — set-request!, make-frame, render-to-string, destroy-frame! — if you would rather see each step before the adapter runs them.

From re-frame v1

v1 had no built-in SSR; you used community libraries and split server and client code by hand. Here it's one handler over the same events and views the client runs. There are no :on-create / :initial-db frame keys: per-request setup is :initial-events, and make-frame warns in dev about a supplied :on-create as an unknown key.

Mounting in a Ring app

A real server also serves static files, an API and form posts. ssr-middleware takes the same options plus a :match? predicate. It sends the requests it matches to the SSR handler and everything else to the handler it wraps:

(require '[clojure.string :as str]
         '[ring.middleware.params   :refer [wrap-params]]
         '[ring.middleware.cookies  :refer [wrap-cookies]]
         '[ring.middleware.session  :refer [wrap-session]]
         '[ring.middleware.resource :refer [wrap-resource]])

(def app
  (-> api-handler                                  ;; your own Ring handler for /api/…
      ((ssr-ring/ssr-middleware
         {:frame     {:initial-events [[:app/server-init]]}
          :root-view (fn [] ((rf/view :app/root)))
          :payload   [:articles :session-user]
          :match?    (fn [req] (and (#{:get :post} (:request-method req))
                                    (not (str/starts-with? (:uri req) "/api/"))))}))
      (wrap-resource "public")                     ;; /main.js and CSS never reach the renderer
      wrap-session
      wrap-cookies
      wrap-params))

:match? defaults to every GET request. Widen it, as here, when the SSR handler must also take a form POST (the form action).

The SSR handler passes the Ring request through unchanged and parses nothing itself. :query-params and :form-params exist only if wrap-params ran first, :cookies only after wrap-cookies, and :session only after wrap-session. Put that middleware outside the SSR handler, as above.

Reading the request

Handlers read the request through a declared coeffect, like any other outside fact:

;; Adapted from examples/capabilities/ssr/ssr/core.cljc
(rf/reg-event :app/server-init
  {:platforms        #{:server}
   :rf.cofx/requires [:rf.server/request]}
  (fn [{:keys [rf.server/request]} _]
    (let [{:keys [uri query-string]} request]     ;; Ring keeps the query out of :uri
      {:fx [[:dispatch [:rf.route/handle-url-change
                        (cond-> uri query-string (str "?" query-string))]]]})))

Declare :rf.cofx/requires [:rf.server/request] and the request map arrives under :rf.server/request: :uri, :query-string, :request-method, :headers, and :query-params, :form-params, :session and :cookies as far as your middleware added them. The [:rf.route/handle-url-change ...] dispatch hands the URL, query included, to the same routing code the client uses. The page's data then comes from the route, as blocking resources.

Use the request for decisions. When a request-derived fact must live in app-db, put it on an event's payload instead of copying it from the request. The handler above does that: the URL it builds lands on the [:rf.route/handle-url-change …] event, so the value is recorded with the dispatch. The shortcut to avoid is (assoc db :session-user (-> request :session :user)), for the reason below.

Why a durable write must be recorded, and how to fix it

Time-travel replays a run only if every input a handler used was captured. The request coeffect, like localStorage or the wall clock, reads a live per-request slot and its value is never recorded — it is an ambient coeffect, not a recordable one stamped onto the event envelope.

An ambient read is fine for a non-durable decision: branch on :request-method, or check a header to pick a code path. It is not fine for a value you write into durable state. On replay the framework re-runs the live supplier instead of re-presenting the value the recorded run saw, and after the per-request frame is torn down that supplier reads nil. So (assoc db :session-user (-> request :session :user)) is a durable write whose input was never recorded, and a replay reconstructs a different app-db.

The fix is to make the fact recordable by putting a sanitised projection on the dispatch itself — never the whole request map, which carries Cookie, Authorization and raw bodies. Two shapes work:

;; (a) pass the derived fact in the event payload — recorded as part of :event.
;;     The host computes the projection and dispatches it:
;;     :initial-events [[:auth/server-init {:user (extract-user request)}]]
(rf/reg-event :auth/server-init
  {:platforms #{:server}}
  (fn [{:keys [db]} [_ {:keys [user]}]]
    {:db (assoc db :auth/user user)}))           ;; durable write, recorded input

;; (b) declare a provided recordable cofx the host stamps onto the boot token.
;;     A record missing it throws :rf.error/missing-required-cofx rather than
;;     silently re-reading the host.
(rf/reg-cofx :auth.session/user {:recordable? true :provided? true} ...)

Never record the whole request map: recording a secret makes it durable, not safe. Record only the sanitised projection, and keep :rf.server/request for reads that don't end up in durable state.

Data the first render needs

The server renders once, so the data a page needs must be loaded before that render. Declare it as a route resource with :blocking? true. The runtime waits for the route's blocking resources to settle and then renders, so the HTML never shows a :loading state for data the server was always going to have:

;; cf. docs/resources/tutorial/02-server-data.md — needs the resources artefact
(def api-origin
  #?(:clj  (or (System/getenv "API_ORIGIN") "http://127.0.0.1:3001")   ;; the server needs an absolute URL
     :cljs ""))                                                         ;; the browser resolves against the page

(rf/reg-resource :articles/one
  {:params-schema [:map [:id :string]]
   :scope         :rf.scope/global}
  (fn [{:keys [id]} _ctx]
    {:request {:method :get :url (str api-origin "/api/articles/" id)}
     :decode  :json}))

(rf/reg-route :articles/show
  {:params    [:map [:id :string]]
   :resources [{:resource  :articles/one
                :params    (fn [route] {:id (get-in route [:params :id])})
                :blocking? true}]}                 ;; the server waits for this before rendering
  "/articles/:id")

The fetch runs on the server too, and the JVM has no page to resolve a relative URL against: a bare "/api/articles/42" fails as :rf.http/transport before any request goes out (Running on the JVM). So the server reads its API origin from configuration, here API_ORIGIN, while the browser keeps the page-relative path.

:app/server-init hands the URL to routing, entering the route starts the resource, and the handler waits for it. Several blocking entries load in parallel, so the wait is the slowest fetch rather than the sum, and the same declaration drives the fetch on client navigation. This is the route's loader. Non-blocking route resources don't hold up the render: whatever has settled by render time is serialised, and anything still in flight refetches on the client.

Only blocking route resources are waited for. The drain after :initial-events covers synchronous work. A fetch those events start — an :rf.http/managed request, or one a route's :on-match events fire — is usually still in flight when the render begins, so the page renders without its data. For the same reason, don't build a loader as a machine that fans out HTTP requests: the render would catch the machine still loading.

The render budget

The wait has a 5-second deadline. A blocking fetch that runs past it settles as a first-load failure: the resource enters :error with {:kind :rf.http/timeout :reason :ssr-blocking-timeout}, the view renders its error state instead of hanging the request, and the runtime records :rf.error/resource-ssr-blocking-timeout.

A slow region

The server renders the whole page in one response; it does not stream regions. To keep one slow region from holding up the page, declare its data as a route resource with :blocking? false and render the resource's :loading state as the region's skeleton. The page ships at once, skeleton included, and the client fetches the region's data after hydration (Show progress across pages). The region fills in once the bundle has booted and the fetch has returned, and a client without JavaScript keeps the skeleton.

:payload — the fail-closed allowlist

:payload decides what is sent to the client, and it fails closed. The vector is an allowlist of top-level app-db keys, and of paths into app-db (Classified values inside the allowlist); everything else stays on the server, including keys you add later. Add a :secrets/api-token to app-db next year and forget the allowlist, and the token still stays on the server. A denylist would do the opposite and leak each new server-only key.

Leave :payload out and the handler throws :rf.error/ssr-missing-payload-policy when it is constructed, before any request is served. To ship nothing, say so with an empty allowlist, :payload []: the payload's :rf/app-db is then {} however much state the server holds. To ship the whole app-db, say so with the keyword :rf.ssr.payload/whole-app-db.

The three boot-time payload errors

The allowlist accepts any sequential of keywords and paths. A literal vector is the usual form, but a computed (filterv …) or (keep …) works too, and one that computes to [] ships nothing. Three construction-time errors tell three mistakes apart:

  • a missing policy — no :payload, or nil — throws :rf.error/ssr-missing-payload-policy;
  • an unknown policy keyword throws :rf.error/ssr-unknown-payload-policy;
  • a string instead of a keyword — ["public/articles"] for [:public/articles] — or any entry that is neither a keyword nor a non-empty vector throws :rf.error/ssr-malformed-payload-allowlist, with the offending entries under :bad-entries. So does a :payload-include-sensitive option, naming the :payload path that carries its permit.

A set is rejected: the allowlist is an ordered selection. Because the policy is either a sequential or a keyword, the two forms can't be confused.

Numbers that change on the way

The payload is printed as EDN on the JVM and read back by the browser, where every number is a double. Some JVM numbers would read back as a different value — an order id landing on its neighbour, money becoming a float — so the handler refuses them. A Long or BigInt past 2^53, a BigDecimal, a Ratio or a Float anywhere in the payload, map keys included, fails the request with :rf.error/ssr-hydration-payload-invalid, naming the path. Numbers that cross on their own can still collide: 1 and 1.0, or 0 and -0.0, are two map keys or set members on the JVM but one in the browser, whose reader then rejects the duplicate, so a map or set holding such a pair fails the same way, naming the colliding values. The check runs in every build, because the failure depends on the data.

Convert those values before they reach an allowlisted key: send ids as strings and money as integer cents, and give colliding numbers one type or make them strings. Or leave the key off :payload if the client doesn't need it.

Values you have classified as sensitive are redacted inside allowlisted keys; Classified values inside the allowlist covers the exceptions.

The client side: hydrate, then adopt

The client has to start in the state the server finished in, without redoing the work. It reads that state from the payload, an EDN <script id="__rf_payload"> in the page:

{:rf/version     1                ;; pattern-protocol version (deploy-drift check)
 :rf/app-db      {…}              ;; your state, filtered by the allowlist
 :rf/runtime-db  {…}}             ;; the framework's serialisable slice — the route, machine snapshots

ssr/hydrate! handles it in two steps, in this order: read the payload, then install it by dispatching [:rf/hydrate payload] before the first render. The adapter's hydrating render then adopts the server's DOM:

;; The core of the client boot. Tutorial Step 4 has the whole `run` fn, and
;; A complete loop below has it in context. app-root is a
;; (reagent-adapter/client-root) created once with defonce.
(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                               ;; no payload: a client-only first load
    (rf/dispatch-sync [:app/client-bootstrap] {:frame :rf/default}))
  ;; payload: adopt the server's DOM; no payload: mount a fresh root
  (reagent-adapter/render! app-root tree el {:hydrate? (some? payload)}))

hydrate! handles the state and returns the payload it applied, or nil. It does not touch the DOM. Adopting the server's DOM is a separate call to the adapter's render! with {:hydrate? true} (React's hydrateRoot underneath): React reconciles against the server markup, keeps the nodes and attaches listeners, and the root reports any mismatch React recovers from (When the renders disagree). Without that option the same call mounts a fresh root and discards the server markup, which is right only for the client-only branch. Tutorial Step 4 builds this boot function step by step.

hydrate! option What it's for
:frame Required. The frame to hydrate — the same one the root frame-provider names.
:payload The payload map, when you already have it. Omit it in the browser and hydrate! reads __rf_payload from the DOM.
:element-id Read the payload from a script with a different id (default "__rf_payload").

Calling it twice for one frame with the same payload is harmless: the second call finds the state installed, skips the install, and returns the payload again. To ask "was this page server-rendered?" without booting anything, call ssr/read-server-payload, which returns the parsed payload or nil. The hydrate! entry lists every option and error.

Two rules:

  • Name the frame. :frame is required, and the same frame goes to hydrate! and to the root frame-provider {:frame …} (frame identity is carried, not found). An absent :frame raises :rf.error/no-frame-context; the runtime never picks a default.
  • Hydration replaces client state. :rf/hydrate installs the server's app-db and its serialisable runtime-db slice in one atomic step — both partitions at once — replacing whatever the client seeded. A malformed payload is rejected whole; a missing one means a normal client-only load, the when-not branch above.
Replace-not-merge, frame-id evidence, and malformed payloads

Why replace, not merge. Hydration replaces the whole frame state (app-db and the serialisable runtime-db projection) because a defaulting merge would hide "which side won?" bugs at every key. State only the client has, such as a draft restored from sessionStorage, is seeded after hydration: dispatch your own event once hydrate! returns. Classified values inside the allowlist shows that boot.

The payload is untrusted input. A non-map payload, or a present-but-not-a-map app-db or runtime-db slice, is rejected whole (:rf.error/malformed-hydration-payload) and the client's existing state is left untouched. A wholly absent slice is not malformed; it is the client-only first-load case.

:rf/frame-id is a check, not a target. The payload may carry the frame id the server rendered under. If present and it disagrees with the :frame you passed, hydration throws :rf.error/hydration-frame-id-mismatch instead of installing the server's state into the wrong frame. If absent — the common case, since the server renders under a per-request frame the client can't name in advance — your :frame stands. The server writes it only when you give ssr-handler a :client-frame-id, for a deployment where both sides agree on one id in advance, such as :rf/default. Never set it to a per-request gensym: the client would refuse every page.

Coming from React?

This is hydrateRoot with the server's state passed explicitly. React hydrates by walking the DOM and attaching listeners, and trusts that your component renders the same tree. Here the payload carries the server's state, :rf/hydrate installs it before the first render, and then the substrate adapter attaches listeners to the existing DOM. The client never re-fetches to catch up.

Resources make the round trip too. The server preloads them, the payload carries the entries, and a fresh hydrated entry renders immediately without a duplicate fetch — see the resources SSR example.

When the renders disagree

Sometimes the client's first render doesn't match the server's HTML — a hydration mismatch. The causes are usually mundane: a date rendered in two timezones, state the server set but the client never read, an unordered map that serialises in two different orders.

React finds these itself. When the adapter's hydrating render meets server markup it cannot adopt — text that differs, or an element that is missing, extra or of the wrong type — React re-renders that part on the client and reports the recovery to the root's onRecoverableError. Every hydrating root installs a reporter there, so each recovery React makes while the root hydrates surfaces as a structured trace event:

{: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 root that reported it

This is reporting hydration recoveries, not a comparison of the two renders: re-frame forwards what React recovered from, wherever it sits in the tree, nested views included. The recovery is warn and replace — React renders the client's view, so the user sees a working page. There is no strict mode; to fail a CI run on a mismatch, watch the root's :on-recoverable-error callback (In the browser). The tutorial's Step 5 triggers a mismatch on purpose.

What is not reported

An attribute-only divergence — a stale class, style or ARIA value on an element whose tag and text match — is not something React recovers from. React keeps the server's attribute, does not patch it, and calls no onRecoverableError, so re-frame emits no trace and no production record for it, in the root view's own markup or anywhere below. React's development build logs a console.error with a diff of the attributes, and that warning is the only signal. Deterministic views are the defence; the browser recipe watches for the warning in development.

Which root reports it

A React-family root must hydrate through its adapter — the first render! through a client root with {:hydrate? true}, or (re-frame.substrate.adapter/render tree el {:hydrate? true}) — not uix.dom/hydrate-root, reagent.dom.client/hydrate-root or hydrateRoot directly: only the adapter's mount installs the reporter.

Substrate Client mount :where on the report
Reagent, native UIx The adapter's render! with {:hydrate? true} re-frame.substrate.spine/make-render
Fresco h/render! with {:hydrate? true} re-frame.fresco.impl.mount/hydrate-root!

Each root reports only while it is hydrating: the window closes on its hydration commit, so a later recovery React makes on that root is not labelled a hydration mismatch. The report names the root and carries React's message, not a path to the node; React's message and its development console output are where to look for which node diverged. The tutorial's Native UIx section has the UIx details, and Fresco → SSR and hydration covers Fresco.

In production, the mismatch arrives as an error record

The trace is part of the dev trace stream, so it is elided from production client builds. Every hydrating root installs the reporter in every build, and each recovery inside the hydration window also emits an always-on error record under the same id — :where, :recovery and, when the root knows it, :frame; no message, markup or state — which reaches your error sinks. Declare a sink and production mismatches reach your monitoring (Deploying).

:platforms — one handler, gated per runtime

A real init flow mixes work that is fine on the server (fetching over HTTP) with work that means nothing there (writing localStorage). You don't branch in handler bodies. The effect declares where it may run:

;; Adapted from examples/core/login/model.cljc
(rf/reg-fx :auth.session/store
  {:doc       "Persist a session token in localStorage."
   :platforms #{:client}}              ;; server-side dispatches skip this
  (fn [_ {:keys [token]}]
    #?(:cljs (.setItem js/localStorage "auth/token" token))))

The default is both (#{:server :client}). When a server-side drain reaches a #{:client} effect, the runtime skips it and emits a :rf.fx/skipped-on-platform trace. The handler never learns which runtime it is on.

Coeffects work the same way. :rf.server/request is #{:server}, so on the client a handler that requires it is not given it (:rf.cofx/skipped-on-platform), and a setup handler that reads the request on the server doesn't break on the client.

Events are not gated: an event registration stores :platforms, but the router never reads it. :app/server-init stays on the server because only the per-request frame's :initial-events dispatch it, and the server-only reads and writes inside it are gated through their coeffects and effects.

When the server throws

A server-side exception must not reach the response as a stack trace, where crawlers and unauthenticated users would read it. A handler, fx, subscription or view that throws is run through a registered error projector, which maps the internal trace to a client-safe :rf/public-error map:

{:status 500 :code :internal-error :message "Something went wrong" :retryable? false}

The default projector maps an unroutable URL to 404 :not-found; a client-supplied event payload that fails its schema, or a client-supplied coeffect rejected at dispatch (:rf.error/cofx-value-invalid), to 400 :bad-request; and anything else to 500 :internal-error. The 404 and 400 arms are narrower than their categories: :rf.error/no-such-handler is a 404 only when its :kind tag is :route (an unregistered event id is a server defect, so 500), and a schema failure is a 400 only when its :where tag is :event. They fire on a release server as well as in dev; What reaches the projector in a release build has the one category that reaches it in dev alone.

Register your own projector to add app conventions, such as mapping thrown auth failures to 401/403. Handle your cases and return nil for everything else: the runtime applies the default projector to that error, so you keep its tag checks:

(rf/reg-error-projector :myapp/public-error
  {:doc "Project internal error traces to public response shapes."}
  (fn [trace-event]
    ;; Your handlers throw (ex-info "…" {:app/error :unauthorised}).
    (case (some-> trace-event :tags :exception ex-data :app/error)
      :unauthorised {:status 401 :code :unauthorised :message "Please sign in." :retryable? false}
      :forbidden    {:status 403 :code :forbidden    :message "Not allowed."    :retryable? false}
      nil)))

A projector must return exactly those four keys, with :status in 400–599, or nil. If it throws, returns an out-of-range status or adds any other key (even its own :details), the runtime emits :rf.error/sanitised-on-projection and serves the generic 500, so a projector bug can't leak anything.

  • The projector is named per frame, with :ssr {:public-error-id :myapp/public-error} on the frame's metadata, so a server-rendering frame and a dev-tooling frame in one process can use different ones.
  • A projected 4xx keeps your app; a projected 5xx gets the error page. A 4xx (a routing miss, an auth 401/403, a 400 your own handler produced) means the app is working: it renders your own not-found or bad-request UI and ships the payload, so the client hydrates into a working app. A 5xx means the app broke mid-drain and app-db is partial, so the framework discards the body and the payload and renders your :error-view (or the default template). An app-set 500 with no projected error stays on your own page.
  • The error page sees only the public map. It is a registered view that receives the four public keys and never the internal trace.
  • Dev builds can add detail. With :ssr {:dev-error-detail? true} the public map gains a :details key holding the full trace; in production that key is absent. This controls how much a projected error says, not whether an error is projected. To see a render-time view exception raw while developing, set :ssr {:on-view-exception :throw}.
  • Monitoring keeps the full trace. Projection applies to the HTTP response only; the full trace still reaches your sinks and the always-on error records.

A route whose :can-enter guard refuses entry throws nothing, so it never reaches the projector. The runtime sets 403 on the response directly, before your :rf.route/entry-denied handler drains — see the entry-denial 403.

The Ring handler has two error options, for two different failures. :error-view renders the page for a 5xx the projector caught (a drain-time exception, a render-time throw); it receives the :rf/public-error map and returns hiccup. :on-error handles a Ring-layer failure the projector can't see (a throw while setting up the per-request frame or while writing headers); it receives the raw (request throwable) and returns a Ring response as is. Wire both:

(ssr-ring/ssr-handler
  {:frame          {:initial-events [[:app/server-init]]
                    :ssr            {:public-error-id :myapp/public-error}}
   :root-view      (fn [] ((rf/view :app/root)))
   :payload        [:articles :session-user]
   ;; A 5xx page, rendered from the public error alone. A registered view id
   ;; works too: :error-view :app/error-page renders [(rf/view :app/error-page) public-error].
   :error-view     (fn [{:keys [status message]}]
                     [:main [:h1 "Error " status] [:p message]])
   ;; A transport failure: return a fixed response and never read the throwable.
   :on-error       (fn [_request _throwable]
                     {:status  500
                      :headers {"Content-Type" "text/html; charset=utf-8"}
                      :body    "<h1>Something went wrong</h1>"})})

A buggy :error-view — one that throws, or that depends on a subscription that recovers to nil — falls back once to the default template without projecting again. A buggy :on-error falls back to a fixed generic 500. Omit :on-error and you get ssr-ring/default-on-error, a plain-text 500 that reveals nothing. The full error story is on the Errors page.

Two patterns, in brief

Two combinations of the pieces above are common enough to name:

  • The SSR loader — the data a page needs before it renders. It is the route's blocking resources, described in Data the first render needs.
  • The form action — a form that posts before JavaScript loads. It is a real method="POST" form with an action; the server routes the POST to the same event the client's :on-submit dispatches. Success usually answers with a 303 redirect (Control the response).

The form action needs wrap-params in front of the handler and a :match? that admits POST (Mounting in a Ring app). Then :app/server-init turns the POST into the domain event:

;; requires [clojure.string :as str]
(rf/reg-event :app/server-init
  {:platforms        #{:server}
   :rf.cofx/requires [:rf.server/request]}
  (fn [{:keys [rf.server/request]} _]
    (let [{:keys [uri query-string request-method form-params]} request
          url (cond-> uri query-string (str "?" query-string))]   ;; Ring keeps the query out of :uri
      {:fx (cond-> [[:dispatch [:rf.route/handle-url-change url]]]
             ;; wrap-params keys form fields by string
             (= :post request-method)
             (conj [:dispatch [:comment/submit (get form-params "body")]]))})))

;; The same event the client's :on-submit dispatches.
(rf/reg-event :comment/submit
  (fn [{:keys [db]} [_ body]]
    (if (str/blank? body)
      {:db (assoc-in db [:comment-form :error] "Write something first.")
       :fx [[:rf.server/set-status 400]]}                ;; re-render the form with its error
      {:fx [[:comments/save body]                        ;; your persistence fx
            [:rf.server/redirect {:status 303 :location "/articles"}]]})))

:rf.server/* fx are server-only, so the client's dispatch of :comment/submit skips them. A real form also checks a CSRF token before acting: ship it to the page with a :payload path and compare it in the handler.

What you give up

Code that renders on the server follows a few rules:

  • Views must be deterministic given the state. A (js/Date.) in a view, or a date formatted in two timezones, causes a mismatch. Put the time in app-db at init.
  • No side effects during render. The tree is a function of state.
  • Browser-only work waits for hydration. Focus traps and observers go in :platforms #{:client} effects.
  • Machine timers start in the browser. A machine's :after timers aren't armed on the server; the server renders the current state and ships the snapshot. After hydration the client arms each active timer for its full delay, counted from then, without re-running entry actions. A machine also can't wait for network I/O during the render; that is what blocking resources are for.

The platform gate enforces the third rule. A view that renders differently on the two sides is reported when React recovers from the difference; an attribute-only difference is not.

A complete loop (server + client)

The whole normal path in one .cljc file. The adapter creates the frame, drains, waits for the route's blocking resource, renders, sends the payload and tears down; the client installs the payload before the first render, and render! with :hydrate? true adopts the server's DOM.

;; cf. examples/capabilities/ssr/resources_ssr/core.cljc
(ns app.ssr
  (:require [re-frame.core :as rf]
            [re-frame.ssr :as ssr]
            [re-frame.routing]
            [re-frame.resources]
            [re-frame.http.managed]
            #?(:clj  [re-frame.ssr.ring :as ssr-ring])
            #?(:clj  [ring.adapter.jetty :as jetty])
            #?(:cljs [re-frame.adapter.reagent :as reagent-adapter])))

(def api-origin
  #?(:clj  (or (System/getenv "API_ORIGIN") "http://127.0.0.1:3001")   ;; the server needs an absolute URL
     :cljs ""))                                                         ;; the browser resolves against the page

(rf/reg-resource :articles/list
  {:params-schema [:map]
   :scope         :rf.scope/global}
  (fn [_params _ctx]
    {:request {:method :get :url (str api-origin "/api/articles")}
     :decode  :json}))

(rf/reg-route :articles/index
  {:resources [{:resource  :articles/list
                :params    (fn [_route] {})
                :blocking? true}]}                   ;; the server waits for it
  "/")

(rf/reg-event :app/server-init
  {:platforms        #{:server}
   :rf.cofx/requires [:rf.server/request]}
  (fn [{:keys [rf.server/request]} _]
    (let [{:keys [uri query-string]} request]     ;; Ring keeps the query out of :uri
      {:fx [[:dispatch [:rf.route/handle-url-change
                        (cond-> uri query-string (str "?" query-string))]]]})))

(rf/reg-event :app/client-bootstrap                  ;; a page nobody server-rendered
  (fn [_ [_ path]]
    {:fx [[:dispatch [:rf.route/handle-url-change path]]]}))

(rf/reg-view ^{:rf/id :app/root} root-view []
  (let [state @(subscribe [:rf/resource {:resource :articles/list
                                         :scope    :rf.scope/global
                                         :params   {}}])]
    [:main
     [:h1 "Recent articles"]
     (cond
       (:loading? state) [:p "Loading…"]
       (:error state)    [:p "Could not load articles."]
       :else             (into [:ul]
                               (for [{:keys [id title]} (:data state)]
                                 ^{:key id} [:li title])))]))

#?(:clj
   (do
     (rf/init! ssr/adapter)
     (def handler
       (ssr-ring/ssr-handler
         {:frame     {:initial-events [[:app/server-init]]}
          :root-view (fn [] ((rf/view :app/root)))
          :payload   [:session-user]}))                   ;; required allowlist
     (defn -main []
       (jetty/run-jetty handler {:port 3000 :join? false}))))

#?(:cljs (defonce app-root (reagent-adapter/client-root)))

#?(:cljs
   (defn ^:export run []
     (rf/init! reagent-adapter/adapter)
     (rf/make-frame {:id :rf/default :platform :client})
     (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                                 ;; client-only: seed first
         (rf/dispatch-sync [:app/client-bootstrap (.-pathname js/location)]
                           {:frame :rf/default}))
       ;; payload ⇒ adopt the server's DOM; no payload ⇒ fresh root
       (reagent-adapter/render! app-root tree el {:hydrate? (some? payload)}))))

The example needs an API that answers GET /api/articles with a JSON array of {"id" …, "title" …} objects. The server fetches it from API_ORIGIN, which defaults to http://127.0.0.1:3001. The browser's relative /api/articles resolves against the page, so serve the API on the page's origin as well, beside the SSR handler as in Mounting in a Ring app.

The route's resource entries travel in the payload with the route, so the hydrated client renders the list without fetching it again. APIs: re-frame.ssr, re-frame.ssr.ring.

Deploying

A few things change between your REPL and a production server:

  • Run the JVM as a release build. It is one by default: with day8/re-frame2-ssr-ring on the classpath the debug flag starts off. That elides development-only work — the trace stream, most schema checks — and keeps what the framework relies on, such as :boundary? true schema checks and error projection. Your REPL and test runs opt back in with -Dre-frame.debug=true; Configure dev and prod has the switches.
  • Declare an error sink. With traces elided, the always-on error records are what reach your monitoring: server-side handler and render failures, and in the browser the hydration-mismatch record. Name a sink with (rf/configure! {:observability {:errors [{:sink :app/sentry}]}}) — Report errors in production.
  • Size the server for one thread per request. Each request gets its own frame, so requests can't see each other's state. ssr-handler runs on the calling server thread, so your server's worker and accept-queue limits bound the requests in flight.
  • Ship the client and server bundles together. The payload is only right for the client built from the same code. The version and schema-digest checks tell you when they drift; they warn rather than stop the page.

Troubleshooting

Symptom Cause Fix
First rf/reg-head / rf/reg-error-projector throws at boot :rf.error/ssr-artefact-missing Require re-frame.ssr (day8/re-frame2-ssr)
ssr-handler construction throws :rf.error/ssr-missing-payload-policy Set a :payload allowlist ([] ships nothing), or :rf.ssr.payload/whole-app-db
Unknown / string allowlist entries :rf.error/ssr-unknown-payload-policy or :rf.error/ssr-malformed-payload-allowlist Keywords and non-empty path vectors only; a sequential, not a set
Request fails naming a payload path :rf.error/ssr-hydration-payload-invalid — a number the browser can't read back, or a map or set whose numbers it reads as one (1 beside 1.0) Send ids as strings, money as integer cents, give colliding numbers one type, or drop the key from :payload
Server HTML is missing data fetched at boot Only blocking route resources are waited for Declare the read as a route resource with :blocking? true
:form-params / :session is nil on the server The Ring middleware didn't run before the SSR handler Wrap with wrap-params / wrap-session outside it
A mismatch is never reported The root was hydrated outside its adapter (hydrateRoot, uix.dom/hydrate-root), or the difference is attribute-only Hydrate through the adapter's render! with {:hydrate? true}; for attributes, read React's development console.error (What is not reported)
hydrate! without :frame :rf.error/no-frame-context Pass the same :frame as frame-provider
Bad payload shape :rf.error/malformed-hydration-payload — client state left untouched Fix the server payload; never ship a non-map
Payload frame id disagrees :rf.error/hydration-frame-id-mismatch Set :client-frame-id only to an id the client hydrates
Client first render ≠ server HTML :rf.ssr/hydration-mismatch trace from the hydrating root; React re-renders the client's view Deterministic views; fail CI from the root's :on-recoverable-error
#{:client} fx during JVM drain :rf.fx/skipped-on-platform trace — skipped, not thrown Expected; declare :platforms on the fx/cofx
Blocking resource hangs past budget :rf.error/resource-ssr-blocking-timeout — resource enters :error Fix the fetch, or render the resource's error state
:on-create frame key :rf.warning/unknown-frame-config-key in dev; the key is never read Use :initial-events, not :on-create

Advanced

Other handler options

Option Use it to
:client-frame-id Stamp a stable frame id into the payload when server and client agree on one in advance (hydrate, then adopt).
:schema-digest Stamp the schema digest the client's deploy-drift check compares.
:error-view, :on-error Render a 5xx page and answer a transport failure — When the server throws.
:script-src, :app-element-id, :lang, :head, :body-end, :html-shell, :content-type Shape the HTML document around your app — The page shell.

The per-request frame's own settings are frame config, so they go inside :frame, beside :initial-events: the :ssr config (the error projector and dev error detail), :fx-overrides (usually to stub :rf.http/managed in tests), and :url-strategy, which keeps route-link hrefs in the server markup matching the hydrated client's. Written at the handler's top level, each is refused at construction with a message naming :frame. Under the Node renderer the request frame does not render the body: hand the render module the same config var as its :frame-opts (Render on Node).

The page shell

The handler wraps your rendered body in a small HTML document, default-html-shell: a doctype, <html lang="en">, the <head> resolved from the active route's head metadata, a <div id="app"> holding the body, the payload <script id="__rf_payload">, and <script src="/main.js">. Most apps change two or three things, all with handler options:

(ssr-ring/ssr-handler
  {:frame          {:initial-events [[:app/server-init]]}
   :root-view      (fn [] ((rf/view :app/root)))
   :payload        [:articles :session-user]
   :script-src     "/js/main.js"       ;; where your client bundle is served
   :app-element-id "root"              ;; must match the element your client mounts into
   :lang           "en-AU"})           ;; <html lang>; a route head's :html-attrs wins
  • :script-src false emits no bootstrap <script src>, for an app that loads its bundle from :body-end, say as a type="module" tag. The payload script still ships.
  • Attributes on <html> and <body> come from the route's head (:html-attrs, :body-attrs — see Head metadata), not from handler options.
  • :content-type replaces the response Content-Type. Omit it and you get text/html; charset=utf-8, or whatever the app set with :rf.server/set-header.
  • :head is a raw HTML string that replaces the resolved head, default viewport meta included — for a static app with no routes. :body-end is raw HTML appended before </body>.
  • :html-shell replaces the whole document with your (fn [body-html payload-edn opts] → string). Use it only when the options above can't produce the page.

A custom shell gets the parts the handler has already built in its opts: :head, the resolved head HTML, and :payload-tag, the finished payload <script id="__rf_payload">, escaped so a </script> inside your data cannot close it. Splice :payload-tag where the payload goes rather than writing the script yourself:

(defn page-shell [body-html _payload-edn {:keys [head payload-tag]}]
  (str "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">" head "</head>"
       "<body><div id=\"app\">" body-html "</div>"
       payload-tag
       "<script src=\"/js/main.js\"></script></body></html>"))

The default-html-shell entry lists everything opts carries.

:head and :body-end are injected unescaped

Never build them from untrusted input — a CMS field, a tenant-admin form, a query parameter. That opens a script-injection hole. Content from outside your trust boundary belongs in reg-head for the head and in ordinary views for the body, which are escaped.

Classified values inside the allowlist

:payload decides which keys are sent; your classification still applies inside them. A path you classify :sensitive reaches the browser as :rf/redacted, while a :large one is sent whole: the payload becomes the client's live state, so it isn't size-trimmed the way a tool feed is.

Sometimes the user's own browser needs a classified value: a CSRF token the page must send back, the signed-in user's email. Name each one by its path in :payload, beside the keys:

(ssr-ring/ssr-handler
  {:frame     {:initial-events [[:app/server-init]]}
   :root-view (fn [] ((rf/view :app/root)))
   :payload   [:session :articles
               [:session :csrf]]})   ;; a path: this classified leaf ships raw

A path ships the value it reaches, so [[:session :csrf]] on its own sends {:session {:csrf …}} and nothing else of the session. It permits exactly the leaf it names — a string, a number, anything but a map, vector, set or sequence — and nothing beside it: a classified sibling stays redacted, and a keyword never permits. Naming a map releases none of its classified descendants, and a path never reaches through a classified ancestor, so if all of :session is sensitive, classify at the leaves instead. A path steps only through maps: through a vector or into a missing key it sends nothing. Never permit a long-lived bearer token. The same paths permit in a Fresco render and in a Node renderer's render state, so the HTML and the payload agree.

A value the browser produced — a password the user is typing — is not permitted; the client re-seeds it after hydrate! installs the server's state:

;; Client boot. read-saved-draft is your own fn — sessionStorage, say.
(ssr/hydrate! {:frame :rf/default})                          ;; install the server's state
(rf/dispatch-sync [:comment/restore-draft (read-saved-draft)] {:frame :rf/default})
;; …then mount with the adapter's render! and {:hydrate? true}, as above.

Deploy-drift checks come along for free

The payload also carries stamps that catch a server and a client running different code. :rf/hydrate fires two client-only checks. Both are best-effort: they emit a trace and let hydration continue; they never throw or block the page.

  • :rf.ssr/check-version compares the payload's pattern-protocol version (:rf/version, an integer) with the client's. A mismatch emits a :rf.ssr/version-mismatch trace: the server bundle is a deploy ahead of the client, or behind it.
  • :rf.ssr/check-schema-digest (only when the payload carries a digest) hashes the client's registered app-schema set and compares it with the server's. A mismatch emits :rf.ssr/schema-digest-mismatch: the server validates against a different schema set than the client's bundle.

The version stamp is automatic. The schema digest is opt-in: compute it once at boot, after your schemas are registered, and pass it to the handler:

;; requires [re-frame.schemas :as schemas]
(ssr-ring/ssr-handler
  {:frame          {:initial-events [[:app/server-init]]}
   :root-view      (fn [] ((rf/view :app/root)))
   :payload        [:articles :session-user]
   :schema-digest  (schemas/app-schemas-digest {:frame :rf/default})})

The client hashes the schemas resolved by the frame it hydrates (:rf/default here), so compute the server's digest through the same frame id. Schemas are top-level registrations, so both sides resolve the same set while they load the same schema namespaces.

The version check always has a client-side value: it reads the SSR artefact's compiled-in protocol constant, the same value the server stamped. The schema-digest check can come up empty: when the client's digest isn't available (the schemas artefact isn't on the classpath), it emits :rf.ssr/compatibility-check-skipped and does nothing. These three traces are part of the dev trace stream, so add an observability listener for them if you want drift reported in CI.

What reaches the projector in a release build

Both specific arms of the default projector fire on a release server. The 404 comes from :rf.error/no-such-handler for an unroutable URL, which is always on. The 400 comes from :rf.error/schema-validation-failure raised by the boundary check a handler turns on with :boundary? true. Ordinary registration checks are development-build assertions (Validate with schemas), but a check the framework relies on to keep one of its own promises runs in every build. The boundary check is one of those, it validates against the handler's own :schema, and its rejection also emits an always-on record, so the projector has something to map. A handler registered {:schema … :boundary? true} therefore answers a malformed request body with 400 under -Dre-frame.debug=false, not a silent 200, as RFC 9110 §15.5.1 asks of a refused payload.

Two categories do not reach the projector on a release server: :rf.error/no-such-route (passing route-url a route id nobody registered — caller misuse rather than hostile input, which route-url throws at the call site in every build, so a miss inside a handler projects as :rf.error/handler-exception), and every other :where surface of :rf.error/schema-validation-failure, which are the elided development assertions.

A validation rule that shapes the response — field-level errors, submitted values kept — still belongs in the handler body with [:rf.server/set-status 400]; the projector gives you the status, not the page. The form action shows that shape.

Several roots on one page

A page can hydrate more than one root — a header and a cart mounted into separate containers, say — and they can share one frame. Boot them with ssr/hydrate-page!, which takes one hydrate! options map per root plus a :mount-fn that mounts it:

;; mount-header! and mount-cart! are your 0-arity fns that call the adapter's
;; render! with {:hydrate? true} into their own containers.
(let [outcomes (ssr/hydrate-page!
                 [{:frame :rf/default :root-id :page/header :mount-fn mount-header!}
                  {:frame :rf/default :root-id :page/cart   :mount-fn mount-cart!}])]
  (doseq [{:keys [root-id error]} outcomes
          :when error]
    (js/console.warn "root did not boot:" (pr-str root-id) error)))

Each root hydrates and mounts inside its own failure boundary: one that throws is reported with an always-on :rf.error/root-boot-failed record and the others carry on, so pass the mount as :mount-fn rather than mounting after the call. The first root installs the payload and the rest find it installed. Two roots installing different payloads into one frame throw :rf.error/frame-payload-conflict before anything is installed.

Render on Node

Everything above renders on the JVM, which works because Reagent hiccup is data a JVM can walk. A native view layer's components are JavaScript, and a JVM cannot call them — Fresco roots are the case in point. For those there is a second renderer: the JVM keeps the request and asks a small Node sidecar for the body markup, and nothing else.

The JVM still owns the request frame, the boot-event drain, the blocking-resource wait, the <head>, __rf_payload, the shell, the status, headers, cookies, redirects, error projection and frame teardown. Node returns a string. Its HTTP status is never copied to the browser, and because the body is rendered before any shell is assembled, there is no partial page to serve.

The worked example is substrates/fresco/login, and both halves of it run. server.cljs is a real render module and host.clj is a real Ring handler; the shared login.model is .cljc, so the JVM holds the application's state the same way the browser does.

1. Build both bundles

The client bundle is the one you already ship. The server bundle is a second build of the same views, targeting :node-library and exporting a render module:

:my-app/server
{:target      :node-library
 :output-dir  "out/my-app-server"
 :output-to   "out/my-app-server/server.js"
 :exports-var my-app.server/module
 :compiler-options {:optimizations :simple
                    :infer-externs false}}

Rebuild and redeploy the two together. The server bundle contains the application's own compiled views, so a skew between it and the JVM host is two different applications answering one request, which the build id in step 5 catches.

2. Write the render module

Use two files. What both halves must agree on — the entry id and the app's frame config — goes in its own .cljc namespace beside the render-state policy the host projects under, because the render module is ClojureScript, the Ring host in step 3 is Clojure, and a .cljc namespace is the only file both compilers read:

(ns my-app.policy
  (:require [re-frame.routing :as rf.routing]))

(def root-entry "my-app/root")

(def render-state-policy
  {:app-db     [:todos :session]
   :runtime-db [:rf.runtime/routing :rf.runtime/machines]})

;; The config every frame of this app is made with, on either host.
(def frame-config
  {:initial-events [[:app/init]]
   :url-strategy   (rf.routing/with-base-path
                     rf.routing/history-url-strategy
                     "/my-app")})

render-state-policy is the one render-visibility policy. The JVM projects under it, and the sidecar checks the shape of what arrives, never which keys, so a key missing from it is refused nowhere: the render reads nil for it and draws the wrong page. Name every key a render reads.

The sidecar renders nothing itself; it loads your bundle. The module publishes a build id, an entry table of the ids a host may name, a once-per-isolate boot and a per-request render. A Fresco bundle builds all four with one re-frame.fresco.server/node-module form:

(ns my-app.server
  (:require [re-frame.core :as rf]
            [re-frame.fresco.server :as server]
            [re-frame.fresco.substrate :as substrate]
            ;; The entry id and frame config, shared with the host in step 3.
            [my-app.policy :as policy]
            [my-app.views :as views]))

;; The development default. A release build overrides it — see step 5.
;; This one stays in the bundle rather than the shared namespace: `goog-define`
;; is a ClojureScript form, and the id is this bundle's own identity.
(goog-define build-id "my-app-dev")

(def module
  (server/node-module
    {:build-id          build-id
     :entries           [policy/root-entry]
     ;; Once per isolate, before the first render.
     :boot              #(rf/init! substrate/adapter)
     :hiccup            [views/root-view]
     :identifier-prefix views/identifier-prefix
     ;; The host's frame config, so `route-link` hrefs carry the same
     ;; base path. Its `:initial-events` do not run here: the JVM
     ;; already drained them.
     :frame-opts        policy/frame-config}))

:boot is where the bundle seats its substrate, and it has no default: re-frame.fresco.substrate is an optional module only the application requires. Leave it out and the first render fails with :rf.error/no-adapter-installed. render reads the request's two state partitions back with re-frame.ssr.render-state/deserialize and renders through re-frame.fresco.server/render-body, whose options :hiccup, :identifier-prefix and :frame-opts are. It renders :hiccup whichever entry the request names, so a bundle whose entries render different roots writes its own module to the same contract, set out in implementation/ssr-node/README.md.

render returns the body markup as a string (or a promise of one), here render-body's answer. That string is the module's only output, and the service refuses a render that returns anything else.

Only projected state crosses to Node. The request carries the two render-state partitions and none of the JVM frame's config, so the render module makes its own frame from :frame-opts; without the shared frame-config, a route-link rendered on Node drops the host's base path.

3. Select the renderer

Add one option; nothing else about the handler changes:

(ns my-app.host
  (:require [re-frame.ssr.ring :as ssr-ring]
            [re-frame.ssr.ring.node :as node]
            ;; The same policy the render module reads.
            [my-app.policy :as policy]))

(defn make-handler [renderer]
  (ssr-ring/ssr-handler
    {;; The frame config the render module hands `:frame-opts` in step 2.
     :frame          policy/frame-config
     ;; What the BROWSER may see — an `ssr-handler` opt.
     :payload        [:todos]
     ;; No :root-view — only the default JVM-local renderer reads one.
     :renderer       renderer}))

;; At boot. This launches the Node sidecar on the server bundle from step 1
;; and waits for it to say it is ready (step 5).
;; `defonce`: each `renderer` under `:module` launches its own
;; sidecar, so a re-evaluated `def` would leave the last one running.
(defonce renderer
  (node/renderer
    {:module       "out/my-app-server/server.js"
     :entry        policy/root-entry
     ;; Must equal the server bundle's `build-id` — the development default
     ;; here, a stamped id in a release.
     :build-id     "my-app-dev"
     ;; What the RENDER may see — a RENDERER opt, because the renderer is
     ;; what projects.
     :render-state policy/render-state-policy
     :timeout-ms   1000}))

(def handler (make-handler renderer))

:render-state goes on the renderer, not on ssr-handler. A copy written at the top level beside :payload looks like the live policy but is silently ignored, because ssr-handler has no such option, so the render projects under whatever the renderer was given. For the same reason, step 2's entry table and this step's :entry read one shared Var rather than two literals that happen to agree today, and so do step 2's :frame-opts and this step's :frame.

node/renderer is validated at construction, and with :module it also starts the sidecar there, so a misconfigured deployment fails at boot rather than at the first request. Omitting :renderer keeps the JVM-local render, unchanged to the byte.

4. Two policies, and why they differ

:payload answers what may the browser see? :render-state answers what does the render need? The answers differ in both directions, and as step 3 shows they are options on two different constructors, because the renderer is what projects the render state.

A server-only value — a deployment notice, an internal flag, an entitlement — belongs in :render-state and must stay out of :payload. Deriving one from the other would either leak it to the browser or render nil where the page expects content. The route slice and machine snapshots live in runtime-db, not app-db, which is why render state is a two-partition map {:rf/app-db {…} :rf/runtime-db {…}} rather than one map.

Whether the runtime-db partition carries anything depends on the host, not the page. A host that has driven a machine — one restoring a session, say — must pass the render the snapshot that decides what to draw, or it renders a signed-in visitor a login form. A host that neither dispatches to the machine nor subscribes to it sends the partition empty, and a self-seeding machine then starts in its initial state in the render's own per-request frame, which is the right page. Name the key either way: the list is the most the deployment may send, not a promise that something fills it.

Both policies are fail-closed allowlists: :payload of top-level keys and paths, :render-state of top-level keys per partition. A classified value in the render state's app-db partition is redacted unless a :payload path permits it, so the markup and the payload agree. The render-state wire refuses the same JVM-only numbers the hydration payload refuses (a Long past 2^53, a BigInt, a BigDecimal, a Ratio, a Float) and the same maps and sets whose numbers the browser reads as one (1 beside 1.0). It is stricter than the payload in one respect: it also refuses ##NaN, #inst and #uuid, which the payload carries, so convert a date to a string or epoch millis before it is sent. Where the allowlist cannot express a projection, :render-state also accepts (fn [frame-id] → partitions). The fn replaces both the allowlist and the frame's classification: the framework still checks the envelope and the wire domain, but which keys cross and whether a :sensitive value is redacted are then yours to decide.

5. Run the sidecar, and mind the skew

The JVM runs it. The sidecar ships inside day8/re-frame2-ssr-ring, whose jar carries the Node service as classpath resources, so there is nothing to install. Given :module, the renderer copies the service to a temp directory and starts node on your server bundle, on a loopback port the operating system picks. It waits for the ready line, the one JSON line the service writes to stdout once it is listening, and checks it before the renderer is returned:

{"rf.ssr-node":"ready","url":"http://127.0.0.1:53817","host":"127.0.0.1","port":53817,"buildId":"my-app-dev","protocol":1}

A bundle built as another id fails construction with :rf.error/ssr-node-build-skew, and a bundle that will not load or a node that is not there fails it with :rf.error/ssr-node-unreachable, quoting the sidecar's last lines of stderr. A deployment that boots is serving the bundle it was configured for.

From then on the renderer supervises the process. If it exits, it is relaunched after 100 ms, and the wait doubles to a cap of 10 s while relaunches keep failing. A request that arrives while it is down fails as :rf.error/ssr-node-unreachable — a projected 5xx, like every other failure. The service keeps no state between requests, so a relaunch loses nothing but warm isolates. The sidecar's stderr, and anything on its stdout besides the ready line, is copied to the JVM's own; a render failure's line there names the request frame's id, so it can be matched to the JVM's error record for the same request.

The renderer is also a java.io.Closeable. (.close renderer) stops the sidecar — call it from your system's stop step, or open the renderer in with-open in a test — and a JVM shutdown hook stops one that nobody closed.

The JVM hands the sidecar its own :timeout-ms, as both the default deadline and its ceiling, and its :admission-ms, so the two halves cannot disagree about either. Two more options shape the process:

  • :isolates <n> — worker threads, each rendering one request at a time (default 2).
  • :node "<path>" — the Node executable, when the node on the JVM's PATH is not Node 24 or later.

Or run it yourself. Give the renderer :endpoint instead of :module — naming both is refused — and it dials a sidecar you run and supervise, on another host for instance. The package is implementation/ssr-node in a checkout of the repository: self-contained CommonJS with no dependencies of any kind, marked private and on no registry. Install it into the host from that directory —

npm install /path/to/re-frame2/implementation/ssr-node

— or add a file: entry to the host's package.json; either puts the re-frame2-ssr-node command in the host's node_modules/.bin. Or copy the directory onto the deployment host and run node bin/serve.cjs with the flags below, which is the same program. Pin that checkout the way you pin the JVM artefacts; the sidecar and the server bundle must move together.

re-frame2-ssr-node --module /srv/my-app/out/my-app-server/server.js

It writes the same ready line. Look for the line whose "rf.ssr-node" key is "ready" rather than assuming it is the first: an application bundle that logs at boot writes to the same stream. Run it under a supervisor that restarts it on exit and stops it with SIGTERM. The flags you are most likely to set:

  • --isolates <n> — worker threads, each rendering one request at a time (default 2).
  • --admission-ms <n> — how long a request waits for a free isolate before a 503 (default 250); give the renderer the same :admission-ms.
  • --timeout-ms <n> / --max-timeout-ms <n> — the render deadline when a request names none (default 1000), and the ceiling on the one it may ask for (default 5000).
  • --port <n> / --host <name> — the bind (default 127.0.0.1:8148, the renderer's default :endpoint).

GET /health answers with the loaded buildId, the published entries and isolate counts; a rising replacements count means the service is killing renders. The full flag list is in the sidecar's README, linked below.

Set the sidecar's deadline below the JVM's own read timeout so it refuses before the caller gives up. The adapter already derives its HTTP timeout as :timeout-ms plus :admission-ms plus a margin, which applies that rule for you.

Build skew is checked at launch, and in both directions after it. A renderer that runs its sidecar refuses to start one whose bundle names another build. On every request the sidecar refuses a buildId that is not its own, and the adapter refuses an answer whose x-rf-ssr-build is not the :build-id it was configured with. The renderer's two refusals are both :rf.error/ssr-node-build-skew. Two artefacts from different builds cannot serve one page between them.

That check is why the two literals above are one value written twice — the bundle's goog-define and the adapter's :build-id — and why the steps above leave both at "my-app-dev". Paste the recipe as written and it renders; change one without the other and the renderer refuses to start — or, dialling a sidecar you run, every request is refused — which is the check working.

Stamp a deployment id into both. "my-app-dev" is a development default and must not reach production, because it says nothing about which build answered. For a release, override the goog-define at build time and give the JVM host the same string:

# One id, named once, written into both artefacts.
BUILD_ID="$(git rev-parse --short HEAD)"

npx shadow-cljs release :my-app/server \
  --config-merge "{:closure-defines {my-app.server/build-id \"$BUILD_ID\"}}"
;; …and the host reads the same id rather than repeating the literal.
:build-id (or (System/getenv "MY_APP_BUILD_ID")
              (throw (ex-info "MY_APP_BUILD_ID is not set" {})))

Deploy with MY_APP_BUILD_ID set to that same $BUILD_ID. Throw on an unset variable rather than defaulting: a host that falls back to "my-app-dev" against a stamped bundle is refused for the skew, and the refusal names the skew rather than the missing variable.

6. Hydrate

Hydration is unchanged: the client reads __rf_payload and adopts the DOM exactly as it does for a JVM render. One extra rule applies to native roots: the :identifier-prefix the server rendered with and the one the hydrating root is given must be the same string. React includes the prefix in every useId, so a mismatch changes every generated id in the tree.

Deployment posture

A sidecar the renderer runs binds loopback. One you run yourself defaults to http://127.0.0.1:8148, the launcher's default bind, and the defaults assume loopback. The adapter accepts any absolute http(s) URL as :endpoint and does not refuse a non-loopback one. Render state may carry server-only values by design, so securing the network and transport to a remote sidecar is the operator's job.

The sidecar is a second production runtime, and that has a cost: size the isolate pool to the concurrency you need (one isolate renders one request at a time, each with its own V8 heap and its own copy of the bundle), and pin the runtime to Node 24 or later.

When it fails

Every failure throws at the render call site with a live frame, so all of them go through the ordinary render-failure projection described in When the server throws: a projected 5xx, never a partial page. Four ids tell them apart in the trace stream: :rf.error/ssr-node-unreachable (no HTTP answer at all), :rf.error/ssr-node-deadline (the deadline passed), :rf.error/ssr-node-refused (any other non-200, carrying the sidecar's own refusal code) and :rf.error/ssr-node-build-skew. A renderer that runs its sidecar can also fail before any request, at construction, with :rf.error/ssr-node-unreachable or :rf.error/ssr-node-build-skew (step 5).

The sidecar's own guarantees, protocol and refusal codes: implementation/ssr-node/README.md.