Documentation
Docs
Introduction
Getting Started
Tutorial: build an app
Core Concepts
Routing
Server-Side Rendering
Mimir, state management
Pages & Layouts
API Routes
Styling & Theming
Building & Deploying
API Reference
Guides
Recipes
FAQ (use cases)
Mimir, SSR and hydration
The reason Mimir lives in rune and not in a generic state library is this chapter: state crosses the server→client boundary cleanly. The server can compute atom values, embed them in the page, and the client picks up exactly those values on its first render, no flicker, no mismatch.
The two ends
- Server: before rendering a page, rune seeds the store from
ssr().__atoms. The render then reads those
values. The same __atoms map is embedded in __EKKO_DATA__.
- Client: during hydration,
mimir.initStore(__atoms)applies the same values before the first
render, so useAtom reads the server's value immediately.
The key is the contract (read this , it's the #1 Mimir bug)
A seed only reaches an atom whose key matches exactly. __atoms: { "app:issues": [...] } fills the atom
declared atom({ key: "app:issues", … }), and nothing else. A typo or a different key , "issues" vs
"app:issues" , means the atom keeps its default and you see empty or stale UI on first load, with no
error. This is the single most common Mimir mistake. Define the key once (in the atom) and reference that same
string when you seed.
The logged-in user: the auth atom
rune carries the request's user across the boundary for you. On a hard load it hydrates the auth key
from req.user (the value your auth middleware set). So your user atom must be keyed exactly "auth":
Key it anything else (e.g. "auth:user") and the logged-in state will be empty after a reload even though
the user is authenticated, a classic, confusing bug. After a client-side login also call
mimir.hydrate({ auth: user }) so the header updates immediately without a round-trip.
Seeding from ssr()
The simplest form: return __atoms as plain key→value pairs. They fill any atom that does not already have
a value:
On the client, the theme atom reads "dark" on the first render, the markup the server produced for "dark"
matches what the client renders.
createStore() , building __atoms with directives
For control over how a seed merges into existing client state, build the __atoms with createStore() on
the server. It records per-atom modes and dehydrate()s to the directive form:
dehydrate() produces:
The three seed modes
When the client (or server) applies __atoms via initStore, each entry behaves by its shape:
| Shape | Behaviour |
|---|---|
a plain value v | fill , set the atom to v only if it has no value yet. Existing client state wins. |
{ __force: true, __value: v } | overwrite , always set the atom to v, replacing any existing value. |
{ __merge: true, __value: v } | deep-merge , recursively merge v into the existing object value (or set v if none). |
Use plain for defaults the server knows (locale, feature flags) that client state should be free to override after load. Use force when the server is authoritative (a freshly loaded cart). Use merge to layer server fields onto an object without clobbering the rest (add one preference, keep the others).
Why there is no flicker or mismatch
The classic SSR state bug: the server renders with value A, the client's first render uses the default B, React reconciles and the UI flashes from B to A (and logs a hydration warning). Mimir avoids it because the client seeds the store before the first render, so the first client render already uses A. The markup the server sent and the markup the client produces are identical, hydration is a clean attach.
This is why seeding the theme atom matters: without it, the first client render would use the default
theme and flash. (For the CSS side of the theme, you also need the no-FOUC inline script that sets the
.dark class before paint, see No-FOUC, the atom keeps the React tree right; the
script keeps the first paint right.)
The cached-page caveat (important)
A static route's HTML is cached and shared across users. So do not seed per-user data into a cached
static route's __atoms, the first user's values would be baked into the cached HTML and served to everyone.
For per-user state:
- seed only non-personal defaults into cached pages, then personalise on the client after hydration, or
- use a dynamic/shell route (rendered per request), which can safely carry per-request seed data.
rune patches the current request's __user into cached HTML per request, so identity can vary per request,
but the seeded atom markup is shared. See SSR → hydration.
Hydrate vs initStore
mimir.hydrate(atoms), a plain bulk set of key→value (no directives), used to splice in known values.mimir.initStore(serverAtoms), the directive-aware seeding used during the SSR handoff (handles
__force/__merge, and, on the client, coordinates with persisted session state).
You normally do not call these yourself; rune calls initStore during hydration with the page's __atoms.
You produce the __atoms from ssr() (optionally via createStore).
Next: surviving a refresh, Persistence & sessions.