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, state for rune

Mimir is rune's state layer. It is small, reactive, and built for the one thing component-tree state is bad at: surviving navigations and reloads. State lives in atoms, standalone units identified by a string key, that exist outside any component. Components subscribe to the atoms they read, and the server can seed atoms before rendering so hydration is seamless.

1
2
3
4
5
6
7
8
import { atom, useAtom } from "ekko:rune/mimir";
 
const themeAtom = atom({ key: "site-theme", default: "dark" });
 
function Toggle() {
const [theme, setTheme] = useAtom(themeAtom);
return <button onClick={() => setTheme(t => t === "dark" ? "light" : "dark")}>{theme}</button>;
}

Mimir data flow

The mental model

  • An atom is a definition: a unique key, a default, and whether it should persist. It holds no

value itself, it is frozen and immutable.

  • The store (one Mimir instance per runtime) holds the live _values (a Map of key → value), the

_listeners (who to notify), and _selectors (derived state). It is the single source of truth.

  • Components read atoms with hooks (useAtom, useAtomValue, useSetAtom). Reading subscribes the

component; writing notifies every subscriber so they re-render.

  • The server can seed the store (via createStore().dehydrate()__atoms) so the client hydrates

with the right values.

  • Persistence (optional, via session(...)) mirrors persist atoms to IndexedDB so they survive a

refresh.

Why not just useState?

useState is fine for state that is local to a component and disposable. But:

useStateMimir atom
Survives a client navigationno (component unmounts)yes (atom is outside the tree)
Survives an F5 / reloadnoyes, with a session (IndexedDB)
Shared across distant componentsneeds lifting/contextyes, just import the atom
Seeded by the server for SSRawkwardbuilt in (ssr().__atoms)
Reactive without a Provider treeyes (no <Provider> needed)

Rule of thumb: if the state should outlive the component that set it, a navigation, or a refresh, it belongs in an atom. Otherwise useState is perfect, use both.

A worked example: what goes where

This is the part newcomers get wrong, they reach for useState for everything and then can't seed it from the server, can't share it, or lose it on a reload. Here is a real board screen with the decision made explicitly for every piece of state. The question each time is: does this need to outlive the component, a navigation, or a reload? Yes, atom. No, useState.

1
2
3
4
5
6
7
// atoms/app.ts , defined ONCE, outside any component, imported wherever needed
import { atom } from "ekko:rune/mimir";
 
export const userAtom = atom<User | null>({ key: "auth", default: null }); // who is logged in
export const themeAtom = atom<"dark" | "light">({ key: "app:theme", default: "dark", persist: true }); // survives F5
export const issuesAtom = atom<Issue[]>({ key: "app:issues", default: [] }); // the data
export const filterAtom = atom({ key: "app:filter", default: { status: "all", q: "" } }); // active filter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// pages/index.tsx , the board
import { useState } from "@ekko/react";
import { useAtomValue, useAtom } from "ekko:rune/mimir";
import { userAtom, issuesAtom, filterAtom } from "../atoms/app";
 
export default function Board() {
const user = useAtomValue(userAtom); // ATOM: server-seeded, must be right on first paint + after F5
const issues = useAtomValue(issuesAtom); // ATOM: server-seeded list, shared with other screens
const [filter, setFilter] = useAtom(filterAtom); // ATOM: so the board REMEMBERS the filter when you leave and come back
const [menuOpen, setMenuOpen] = useState(false); // useState: a transient UI flag, dies with the component, fine
 
const visible = issues.filter(i =>
(filter.status === "all" || i.status === filter.status) &&
(!filter.q || i.title.toLowerCase().includes(filter.q.toLowerCase())));
 
return <Layout user={user}>{/* the filtered list, the menu toggled by menuOpen, ... */}</Layout>;
}

Why each choice:

  • user is an atom, keyed "auth". The server already knows who is logged in and seeds the auth atom,

so the header is correct on the first paint and after a reload, with no flicker and no refetch. useState would render null first and then flash to the user.

  • theme is an atom with persist: true. A preference must survive F5 and be readable from any

component (the header toggle, the page background). That is a session-persisted atom, not component state.

  • issues is an atom. The list is server-seeded for SSR (via ssr().__atoms) and shared across

the board, the detail page, and the counts. The useState alternative is to lift it to a common ancestor and prop-drill; an atom is that shared source without the plumbing.

  • filter is an atom. Open an issue, press back, the board should still show your filter. State that must

survive a client navigation lives outside the component tree, so it is an atom.

  • menuOpen is useState. A dropdown's open/closed is local, transient, and meaningless once you leave

the screen. Keep it in the component. Use both: atoms for app state, useState for throwaway UI.

The tell is almost always that one question. Server-seeded, shared, or must-survive-a-nav/reload, atom. Ephemeral, single-component UI, useState.

What's in this chapter

PageCovers
AtomsDefining atoms: key, default, persist.
Reading & writinguseAtom, useAtomValue, useSetAtom, updater functions, reset.
SelectorsDerived state with automatic dependency tracking.
Subscriptionssubscribe, the imperative store API, outside React.
SSR & hydrationSeeding from the server, createStore, force/merge.
Persistence & sessionsnone / ephemeral / domain, IndexedDB, F5-tolerance.
PatternsTheme, forms, async data, lists, derived totals.
PitfallsMistakes that bite, and how to avoid them.

Server and client are the same API

Mimir ships in two implementations, a server-side one (used during SSR) and a client-side one (used in the browser), but they expose the same surface: atom, selector, useAtom, useAtomValue, useSetAtom, createStore, and a mimir instance with get/set/reset/subscribe. You write your atoms once and they work in both environments; the seeding mechanism keeps the values in sync across the boundary.

The client version adds what only makes sense in a browser: useSyncExternalStore-style subscriptions that drive React re-renders, and IndexedDB persistence. The server version is a one-shot store used to produce a single HTML render. You rarely think about which one you are in, that is the point.

Next: defining atoms, Atoms.