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)

Tutorial , 4. State with Mimir

Time for state. We will move notes into a Mimir atom (so the list updates live when we add one), add a selector for the count, and make the theme toggle real, persisted so it survives a reload.

The notes atom

1
2
3
4
5
6
7
8
// atoms/notes.ts
import { atom, selector } from "ekko:rune/mimir";
import type { Note } from "../lib/notes";
import { seedNotes } from "../lib/notes";
 
export const notesAtom = atom<Note[]>({ key: "notes:list", default: seedNotes });
 
export const noteCount = selector({ key: "notes:count", get: ({ get }) => get(notesAtom).length });

Read the atom in the list

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// pages/index.tsx
import { Link } from "ekko:rune/router";
import { useAtomValue } from "ekko:rune/mimir";
import { notesAtom, noteCount } from "../atoms/notes";
 
export function ssr() { return { title: "Notes" }; }
 
export default function Home() {
const notes = useAtomValue(notesAtom);
const count = useAtomValue(noteCount);
return (
<section>
<h1>Notes <small>({count})</small></h1>
<ul className="note-list">
{notes.map(n => <li key={n.id}><Link href={`/notes/${n.id}`}>{n.title}</Link></li>)}
</ul>
</section>
);
}

A "new note" form that updates the atom

Add pages/notes/new.tsx. Note this is a static route (/notes/new) and beats the dynamic /notes/:id because static has higher priority (see File-based routing).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// pages/notes/new.tsx
import { useState } from "@ekko/react";
import { useSetAtom } from "ekko:rune/mimir";
import { useRouter } from "ekko:rune/router";
import { notesAtom } from "../../atoms/notes";
 
export function ssr() { return { title: "New note" }; }
 
export default function NewNote() {
const setNotes = useSetAtom(notesAtom);
const { navigate } = useRouter();
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
 
function save() {
const id = title.toLowerCase().replace(/\s+/g, "-") || String(Date.now());
setNotes(notes => [...notes, { id, title: title || "Untitled", body }]); // new array → notifies
navigate(`/notes/${id}`);
}
 
return (
<section>
<h1>New note</h1>
<input placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} />
<textarea placeholder="Body" value={body} onChange={e => setBody(e.target.value)} />
<button onClick={save}>Save</button>
</section>
);
}

Register it:

1
2
import * as NewNote from "./pages/notes/new";
const modules: any = { "/": Home, "/notes/new": NewNote, "/notes/:id": NotePage };

Now: create a note → the list shows it and the count selector updates, because both read notesAtom, and the new array notifies subscribers. We never lifted state or passed props; the form and the list just share the atom.

We used useSetAtom in the form (it only writes) and useAtomValue in the list (it only reads). Reach for useAtom when a component does both. See Reading & writing.

Make the theme toggle real

1
2
3
// atoms/theme.ts
import { atom } from "ekko:rune/mimir";
export const themeAtom = atom({ key: "site-theme", default: "dark" });
1
2
3
4
5
6
7
8
9
10
11
12
// components/ThemeToggle.tsx
import { useAtom } from "ekko:rune/mimir";
import { themeAtom } from "../atoms/theme";
 
export default function ThemeToggle() {
const [theme, setTheme] = useAtom(themeAtom);
return (
<button className="theme-btn" onClick={() => setTheme(t => t === "dark" ? "light" : "dark")}>
{theme === "dark" ? "🌙" : "☀️"}
</button>
);
}

We also need to put the .dark class on <html> so the SCSS variables flip. Add a tiny bridge the layout renders:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// components/ThemeBridge.tsx
import { useEffect } from "@ekko/react";
import { mimir } from "ekko:rune/mimir";
import { themeAtom } from "../atoms/theme";
 
export default function ThemeBridge() {
useEffect(() => {
mimir.session("domain"); // persist the theme across reloads
const apply = (v: string) => document.documentElement.classList.toggle("dark", v !== "light");
apply(mimir.get(themeAtom));
return mimir.subscribe(themeAtom, apply);
}, []);
return null;
}

Render <ThemeBridge /> once in the layout. Now toggling flips the theme, and mimir.session("domain") persists it: reload the page and your choice sticks.

What just happened

  • State lives in atoms, outside the component tree, so the list, the form, and the count stay in sync and

survive navigation.

  • A selector (noteCount) derives from notesAtom and recomputes only when the notes change.
  • A session persists the theme to IndexedDB, surviving an F5.

There is still a flash of the wrong theme on first paint; we fix that with ssr() seeding + a no-FOUC script next.

Next: server rendering and seeding state, 5. SSR and data.