Documentation

Docs

Introduction

What is rune

Philosophy

Why rune

Architecture

Getting Started

Installation

Quick start

Project structure

The dev loop

Tutorial: build an app

1. Create the app

2. Pages & routes

3. Layouts

4. State with Mimir

5. SSR & data

6. API routes

7. Styling

8. Build & deploy

Core Concepts

The application object

Rendering pipeline

Hydration

The build manifest

Configuration (ekko.json)

Permissions

Conventions

Routing

File-based routing

Dynamic routes

The router (useRouter)

Navigation & Link

Guards & redirects

Programmatic routes

Server-Side Rendering

Overview

The ssr() function

Strategies (eager/lazy)

SSR → hydration

Caching & invalidation

SEO

Mimir, state management

Overview

Atoms

Reading & writing

Selectors (derived state)

Subscriptions & the store

SSR & hydration

Persistence & sessions

Patterns & recipes

Pitfalls

Pages & Layouts

Pages

Layouts

Error & not-found

API Routes

Defining routes

Request & response

Middleware

helmet

cors

rateLimit

bodyLimit

validateContentType

csrf

requestId

timeout

errorHandler

httpsRedirect

secureCookies

ipFilter

safePath

Validation & options

Styling & Theming

SCSS

Theming (light/dark)

Asgard integration

No flash (no-FOUC)

Building & Deploying

The build

Static assets

Production deploy

API Reference

ekko:rune

ekko:rune/router

ekko:rune/mimir

ekko:rune/seo

ekko:ssr / css

ekko.json schema

CLI commands

Guides

Rune app from scratch

Recipes

Dark mode

Forms

Data fetching

Authentication

Pagination

FAQ (use cases)

Documentation

Docs

Introduction

What is rune

Philosophy

Why rune

Architecture

Getting Started

Installation

Quick start

Project structure

The dev loop

Tutorial: build an app

1. Create the app

2. Pages & routes

3. Layouts

4. State with Mimir

5. SSR & data

6. API routes

7. Styling

8. Build & deploy

Core Concepts

The application object

Rendering pipeline

Hydration

The build manifest

Configuration (ekko.json)

Permissions

Conventions

Routing

File-based routing

Dynamic routes

The router (useRouter)

Navigation & Link

Guards & redirects

Programmatic routes

Server-Side Rendering

Overview

The ssr() function

Strategies (eager/lazy)

SSR → hydration

Caching & invalidation

SEO

Mimir, state management

Overview

Atoms

Reading & writing

Selectors (derived state)

Subscriptions & the store

SSR & hydration

Persistence & sessions

Patterns & recipes

Pitfalls

Pages & Layouts

Pages

Layouts

Error & not-found

API Routes

Defining routes

Request & response

Middleware

helmet

cors

rateLimit

bodyLimit

validateContentType

csrf

requestId

timeout

errorHandler

httpsRedirect

secureCookies

ipFilter

safePath

Validation & options

Styling & Theming

SCSS

Theming (light/dark)

Asgard integration

No flash (no-FOUC)

Building & Deploying

The build

Static assets

Production deploy

API Reference

ekko:rune

ekko:rune/router

ekko:rune/mimir

ekko:rune/seo

ekko:ssr / css

ekko.json schema

CLI commands

Guides

Rune app from scratch

Recipes

Dark mode

Forms

Data fetching

Authentication

Pagination

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":

1
2
3
4
5
// atoms/auth.ts
export const userAtom = atom<User | null>({ key: "auth", default: null });
 
// layout header (or anywhere)
const user = useAtomValue(userAtom); // the logged-in user, correct after a reload, no fetch, no flicker

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:

1
2
3
4
5
6
export function ssr() {
return {
title: "Home",
__atoms: { "site-theme": "dark", "locale": "en" },
};
}

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:

1
2
3
4
5
6
7
8
9
10
import { createStore } from "ekko:rune/mimir";
 
const store = createStore();
store.set(themeAtom, "dark"); // plain -> fill only if unset
store.set(cartAtom, { items: [] }, { force: true }); // force -> overwrite
store.set(prefsAtom, { density: "compact" }, { merge: true }); // merge -> deep-merge
 
export function ssr() {
return { title: "Home", __atoms: store.dehydrate() };
}

dehydrate() produces:

1
2
3
4
5
{
"site-theme": "dark",
"cart:items": { "__force": true, "__value": { "items": [] } },
"prefs": { "__merge": true, "__value": { "density": "compact" } }
}

The three seed modes

When the client (or server) applies __atoms via initStore, each entry behaves by its shape:

ShapeBehaviour
a plain value vfill , 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.