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)

FAQ — common use cases

Each answer below is a complete, copy-whole snippet. They encode the patterns that are easy to get subtly wrong; copy the whole answer rather than assembling from memory.

How do I read the request body in an API route?

The body accessors are async — you must await them, and the handler must be async. req.json() returns a Promise, not the parsed value. They are content-type-independent and single-use.

1
2
3
4
5
6
7
8
9
10
11
12
// `req` is the WHATWG Request. await one accessor:
app.api("POST", "/api/notes", async (req) => {
const { title, body } = await req.json(); // ← await is REQUIRED (req.json() is a Promise)
if (!title) return { error: "title is required" };
return createNote({ title, body }); // a returned value is sent as JSON
});
 
// Other accessors (pick one — the body is single-use):
// await req.text() → string (UTF-8)
// await req.bytes() → Uint8Array
// await req.arrayBuffer() → ArrayBuffer
// req.body → ReadableStream | null (stream a large upload without buffering)

How do I theme the whole page with a dark/light toggle?

ThemeProvider themes Asgard components; it does not theme your own page. Bridge the active theme to :root so your CSS themes too, and apply the theme in the SSR <head> so the first paint is themed (no flash). The active mode lives in a Mimir atom so it survives navigation.

1
2
3
// atoms/theme.ts — the single source of truth
import { atom } from "ekko:rune/mimir";
export const themeAtom = atom({ key: "theme", default: "dark" }); // "dark" | "light"
1
2
3
4
5
6
7
8
// server.tsx — theme the FIRST paint in the head (SSR; ThemeCssVars is client-only, so this avoids FOUC)
import { themeToCssVars, themes } from "@ekko/asgard/theme";
const vars = themeToCssVars(themes.githubDark); // { "--ekko-background-primary": "#0d1117", ... }
const head =
`<meta name="viewport" content="width=device-width, initial-scale=1">` +
`<style>:root{${Object.entries(vars).map(([k, v]) => `${k}:${v}`).join(";")}}</style>` +
`<script>document.documentElement.classList.add('dark')</script>` + // default dark before paint
seo.headTags();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// components/Themed.tsx — render once in the root layout: re-themes Asgard AND your CSS on toggle
import { ThemeProvider, ThemeCssVars, themes } from "@ekko/asgard";
import { useAtomValue } from "ekko:rune/mimir";
import { themeAtom } from "../atoms/theme";
 
export default function Themed({ children }: { children: any }) {
const mode = useAtomValue(themeAtom);
const theme = mode === "light" ? themes.githubLight : themes.githubDark;
return (
<ThemeProvider theme={theme}>
<ThemeCssVars /> {/* mirrors `theme` onto :root as --ekko-* on the client */}
{children}
</ThemeProvider>
);
}
1
2
3
4
/* your SCSS style the PAGE (incl. background) from the same variables */
body { background: var(--ekko-background-primary); color: var(--ekko-text-primary); }
.card { background: var(--ekko-background-elevated); border: 1px solid var(--ekko-border-default); }
a { color: var(--ekko-accent-primary); }
1
2
3
4
5
6
7
// the toggle (anywhere)
import { useAtom } from "ekko:rune/mimir";
import { themeAtom } from "../atoms/theme";
function ThemeToggle() {
const [mode, setMode] = useAtom(themeAtom);
return <button onClick={() => setMode(mode === "light" ? "dark" : "light")}>{mode === "light" ? "🌙" : ""}</button>;
}

How do I upload an image and show a preview (with crop)?

UploadZone reports files via onFilesAdd (not onChange), and it is only the dropzone — it does not render the preview. Each image file carries a preview data URL. Give the cropper a sized container, and read the cropped result from getCroppedImage() (returns { blob, dataUrl }).

Like every Asgard component, UploadZone and ImageCropper must render inside a <ThemeProvider> (your root layout provides one — see theme the whole page above). Rendered outside one they throw useTheme must be used within a ThemeProvider.

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
30
31
import { UploadZone, ImageCropper } from "@ekko/asgard";
import { useState, useRef } from "@ekko/react";
 
function ScreenshotField() {
const [cropSrc, setCropSrc] = useState<string | null>(null);
const [image, setImage] = useState<string | null>(null);
const cropper = useRef<any>(null);
 
// 1) final preview after crop
if (image) return <img src={image} alt="Screenshot" style={{ width: 240, borderRadius: 8 }} />;
 
// 2) crop step — the container MUST have a height or the cropper renders empty
if (cropSrc) return (
<div style={{ height: 320 }}>
<ImageCropper ref={cropper} src={cropSrc} aspectRatio="16:9" />
<button onClick={async () => {
const out = await cropper.current?.getCroppedImage("png"); // { blob, dataUrl }
if (out) { setImage(out.dataUrl); setCropSrc(null); }
}}>Apply</button>
</div>
);
 
// 3) the dropzone — onFilesAdd (NOT onChange); files[0].preview is a data: URL
return (
<UploadZone
config={{ accept: ["image/*"], multiple: false }}
onFilesAdd={(files) => { if (files[0]?.preview) setCropSrc(files[0].preview); }}
title="Drop an image" description="or click to browse"
/>
);
}

Don't need cropping? Skip the cropper: onFilesAdd={f => setImage(f[0]?.preview ?? null)} then <img src={image}>.

How do I keep client state (that survives navigation)?

Use a Mimir atom, not React useState — an atom survives client-side navigation (and, with a session, reloads). It is SSR-safe.

1
2
3
4
5
6
7
8
9
import { atom, useAtom, useAtomValue } from "ekko:rune/mimir";
 
const countAtom = atom({ key: "count", default: 0 });
 
function Counter() {
const [n, setN] = useAtom(countAtom);
return <button onClick={() => setN(n + 1)}>Clicked {n}</button>;
}
function ReadOnly() { const n = useAtomValue(countAtom); return <span>{n}</span>; }

How do I define a database model and query it?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { connect, defineTable, col } from "ekko:db/orm";
 
const Note = defineTable("notes", {
id: col.int().primaryKey().autoIncrement(),
title: col.text(),
body: col.text().nullable(),
done: col.bool().default(false),
});
 
const db = connect(":memory:"); // or connect("./app.db") / connect("postgres", { ... })
db.createTable(Note);
 
db.from(Note).insert({ title: "First", body: "hello" }).exec();
const all = db.from(Note).toArray(); // all rows
const one = db.from(Note).where({ id: 1 }).first(); // one row or null
db.from(Note).where({ id: 1 }).update({ done: true }).exec();

How do I read the query string?

req.query is the raw string (e.g. "?q=hi&page=2"), not a parsed object — parse it with URLSearchParams.

1
2
3
4
5
6
app.api("GET", "/api/search", async (req) => {
const params = new URLSearchParams(req.query.replace(/^\?/, ""));
const q = params.get("q") || "";
const page = Number(params.get("page") || "1");
return search(q, page);
});

Where do server-only imports go?

Anything that must not reach the browser (DB, fs, secrets) goes inside the SSR markers; code outside them is bundled to the client.

1
2
3
4
/* START SSR */
import { connect } from "ekko:db/orm"; // server-only — stays out of the client bundle
const db = connect("./app.db");
/* END SSR */