Skip to content

Coming from Next.js

Next.js and re-frame2 SSR do the same jobs: render HTML on the server, hydrate it on the client, load data before the page paints, and stream slow regions later. The main structural difference is that re-frame2 has no separate server layer. The event handlers, subscriptions and views you write for the browser run unchanged on the JVM, against a per-request frame. There is no Server/Client component split; code that must run on one side only is declared on the effect with :platforms.

The mapping

Next.js re-frame2
Server Component Any event handler, subscription or view. They are pure, so they run on either side; there is no separate component kind.
Client Component ("use client") The same view, after hydration. Browser-only effects are declared with :platforms #{:client}.
"use server" / "use client" directives :platforms on an effect or coeffect, declared once where the effect is registered.
getServerSideProps (Pages Router) Your ordinary events, dispatched by the per-request frame's :initial-events and drained before the render. The drain settles synchronous work only; data fetched over the network belongs in a route's blocking resource (next rows).
A page's data fetching (fetch in a Server Component) A route loader: the :resources the route declares, loaded on route entry (on the server during the request, on the client on navigation).
awaiting data in a Server Component before it renders A route resource declared :blocking? true. The Ring handler waits for it, within a 5-second budget, before rendering. A fetch your :initial-events start themselves is not waited for. See blocking resource.
Promise.all of N fetches The SSR loader pattern: several :blocking? true entries in the route's :resources. They load in parallel, so the wait is the slowest fetch, and the same declaration drives the fetch on client navigation.
Server Action (form action={fn}) The form-action pattern: a real method="POST" form routes to the same event the client's :on-submit dispatches.
hydrateRoot Two calls. ssr/hydrate! installs the server's state from the payload before the first render; the adapter's render! with {:hydrate? true} then adopts the DOM.
Several hydrateRoot calls on one page (islands) ssr/hydrate-page!: each root hydrates and mounts inside its own failure boundary, usually into one shared frame.
Reading cookies() / headers() A declared coeffect: :rf.cofx/requires [:rf.server/request], with the value in the handler's coeffects. The value is the Ring request, so :cookies is there only when Ring's wrap-cookies runs in front.
cookies().set(...) The :rf.server/set-cookie effect, which takes a map. See Controlling the response.
redirect() / notFound() The :rf.server/redirect effect; [:rf.server/set-status 404], or an unmatched URL, which the default error projector answers with 404.
error.js / global-error.js The error projector maps the failure to a sanitised public error, and a 5xx renders ssr-handler's :error-view from that alone.
The Metadata API / generateMetadata reg-head: a head model derived from app-db, a pure function of (db, route).
The root layout.js / _document.js document The page shell: default-html-shell, adjusted with :head, :body-end, :script-src and :app-element-id, or replaced with :html-shell.
<Suspense fallback> + loading.js (streaming) ssr/boundary: one component with an :id and a :fallback, whose subtree streams in as its own chunk.
Hydration mismatch (console warning, content flash) A hydration mismatch trace, from a structural hash comparison, plus a strict mode that throws in CI. That covers views that return hiccup; UIx and Fresco roots rely on React's own hydration check.
unstable_cache / fetch cache A resource: loaded on the server, shipped in the payload, and rendered on the client without a second fetch.
next/server runtime, route handlers, middleware The Ring adapter, day8/re-frame2-ssr-ring: ssr-handler returns a Ring handler, and ssr-middleware mounts it inside an existing Ring app. See Ring handler.
Rendering in Node The JVM renders by default. A native view layer such as Fresco renders its body on a Node sidecar through the Node renderer, while the JVM keeps the request, the payload and the response.
next build / NODE_ENV=production A production build: an :advanced client bundle, and the server JVM started with -Dre-frame.debug=false.

Differences to know

One declaration loads data on both sides. A route's :resources drive the fetch on the server during the request and on the client during navigation, so there is no server-only data function to keep in step with a client one. The per-request frame runs its :initial-events, drains, waits for the route's blocking resources, and then renders.

The client receives the server's state as well as its HTML. hydrate! installs the server's app-db and the serialisable runtime-db slice before the first render, so the client does not fetch again to catch up. For views that return hiccup, the client also checks its first render against a hash of the server's; see When the renders disagree.

What reaches the client is an allowlist. Next.js serialises whatever props your loader returns. re-frame2's :payload names the top-level app-db keys that may ship; every other key stays on the server, including keys added later. There is no denylist form, and constructing a handler without :payload throws at boot.

The head is not updated on client navigation. generateMetadata re-runs on an App Router navigation and Next.js updates the live document head. re-frame2 ships no DOM-head reconciler, so refreshing <title> and <meta> after a client-side route change is the app's job, reading the same head model. The head's :rf/head-hash is written but not compared on hydration; only the body's :rf/render-hash is. Head metadata covers both.

Response control is data. Status, headers, cookies and redirects are server-only :rf.server/* effects returned from handlers. Cookies are maps, and a CR, LF or NUL in a header value, redirect location or cookie attribute throws. See Controlling the response.

A streaming boundary is the same form on both sides. ssr/boundary defers its subtree on the server and renders it in the browser. A boundary that throws keeps its fallback while the rest of the page streams, and the final chunk carries the full payload, which wins over the per-region deltas. See Streaming.