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)

A Rune app from scratch

For a real project, start with ekko init rune instead. This page builds an app by hand to show how all the pieces fit together, it is for understanding, not the recommended way to start. The scaffold writes the same correct structure for you.

This is one small, complete Rune app you can copy verbatim. It has two pages, client navigation, a Mimir atom, an Asgard form that POSTs to a same-process API route, and themable chrome. Every snippet uses the correct patterns, so it runs as written.

If anything here looks unfamiliar, see What's different in Ekko: ESM only, ekko: prefixes, extensionless code imports, value-not-event input handlers, and a raw-string req.body.

The file layout

my-app/
  ekko.json
  server.tsx
  atoms/
    greeting.ts
  pages/
    layout.tsx
    index.tsx
    about.tsx
  styles/
    global.scss

1. ekko.json

Type run, with only the valid permission keys. This app reads files (fs), listens and fetches (net), and hashes a value (crypto).

1
2
3
4
5
6
7
8
9
10
11
{
"name": "my-app",
"version": "1.0.0",
"type": "rune",
"permissions": { "fs": true, "net": true, "crypto": true },
"ship": {
"@ekko/react": "^19.0.0",
"@ekko/react-dom": "^19.0.0",
"@ekko/asgard": "^1.0.0"
}
}

The valid permission categories are exactly fs, net, crypto, process, env, ffi, and all. Any other key warns and grants nothing. See Permissions.

2. server.tsx, the entry point

It registers the renderer, compiles SCSS at startup, maps page modules to routes, declares one API route using await req.json(), and starts listening.

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
32
33
34
35
36
37
38
39
40
// server.tsx
import "@ekko/react";
import "@ekko/react-dom/server";
import { createApp, scanRoutes, readManifest } from "ekko:rune";
import { compileSass } from "ekko:ssr/css";
import { readText } from "ekko:fs";
 
import RootLayout from "./pages/layout";
import * as Home from "./pages/index";
import * as About from "./pages/about";
 
// Compile the design system once, at server start.
const css = compileSass(readText("styles/global.scss"));
 
const app = createApp({
port: 3000,
manifest: readManifest(),
layouts: { "": { layouts: [{ render: RootLayout }] } },
head: `<style>${css}</style>`,
ssr: "eager",
});
 
// Map each discovered route to its imported module.
const modules: any = { "/": Home, "/about": About };
for (const r of scanRoutes("pages")) {
const mod = modules[r.pattern];
if (mod) app.page(r.pattern, mod, { page: r.pageKey });
}
 
// One API route. req.body is the RAW string; await req.json() parses it.
app.api("POST", "/api/greet", async (req, res) => {
const { name } = await req.json() ?? {};
if (typeof name !== "string" || !name.trim()) {
res.status(400);
return { error: "name is required" };
}
return { greeting: `Hello, ${name.trim()}!` };
});
 
app.start();

Imports have no file extension: ./pages/index, not ./pages/index.tsx. Ekko owns module resolution and rejects extensioned code specifiers at run. See Why extensionless?.

3. A Mimir atom

State that survives navigation lives in an atom, not useState.

1
2
3
4
// atoms/greeting.ts
import { atom } from "ekko:rune/mimir";
 
export const greetingAtom = atom<string>({ key: "greeting", default: "" });

4. The layout

The shell wraps every page. It hosts the Asgard ThemeProvider, and <ThemeCssVars/> mirrors the active theme onto :root as CSS variables so your own SCSS chrome themes too.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// pages/layout.tsx
import { ThemeProvider, ThemeCssVars, themes } from "@ekko/asgard";
import { Link } from "ekko:rune/router";
 
export default function RootLayout({ children }: { children: any }) {
return (
<ThemeProvider theme={themes.light}>
<ThemeCssVars />
<div className="app">
<header className="bar">
<Link href="/">Home</Link>
<Link href="/about">About</Link>
</header>
<main>{children}</main>
</div>
</ThemeProvider>
);
}

<ThemeProvider> themes Asgard components; <ThemeCssVars/> exposes the same theme tokens as CSS variables (for example var(--ekko-background-primary)) so your markup matches. See Asgard integration.

5. The home page, an Asgard form

The page reads and writes the atom, renders an Asgard TextBox and Button, and POSTs to the API. Note two intentional patterns: the input onChange receives the value (not an event), and the submit button uses htmlType="submit".

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
32
33
34
35
36
37
// pages/index.tsx
import { useState } from "@ekko/react";
import { useAtom } from "ekko:rune/mimir";
import { Button, TextBox } from "@ekko/asgard";
import { greetingAtom } from "../atoms/greeting";
 
export function ssr() {
return { title: "Home, my-app" };
}
 
export default function Home() {
const [name, setName] = useState("");
const [greeting, setGreeting] = useAtom(greetingAtom);
 
async function submit(e: any) {
e.preventDefault();
const res = await fetch("/api/greet", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
const data = await res.json();
if (res.ok) setGreeting(data.greeting);
}
 
return (
<section>
<h1>Say hello</h1>
<form onSubmit={submit}>
{/* Asgard inputs pass the VALUE to onChange, by design, not an event. */}
<TextBox value={name} onChange={(v) => setName(v)} placeholder="Your name" />
<Button htmlType="submit" variant="filled">Greet</Button>
</form>
{greeting ? <p className="result">{greeting}</p> : null}
</section>
);
}

The greeting is stored in greetingAtom, so it is still there when you navigate to About and back.

6. The about page

A plain page that reads the atom set on the home page, proof the state survived navigation.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// pages/about.tsx
import { useAtomValue } from "ekko:rune/mimir";
import { Link } from "ekko:rune/router";
import { greetingAtom } from "../atoms/greeting";
 
export function ssr() {
return { title: "About, my-app" };
}
 
export default function About() {
const greeting = useAtomValue(greetingAtom);
return (
<section>
<h1>About</h1>
<p>A tiny Rune app.</p>
{greeting ? <p>Last greeting: {greeting}</p> : <p>No greeting yet.</p>}
<p><Link href="/">Back home</Link></p>
</section>
);
}

7. The styles

global.scss is compiled at server start. It can lean on the CSS variables <ThemeCssVars/> publishes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// styles/global.scss
:root {
--gap: 1rem;
}
 
.app {
max-width: 48rem;
margin: 0 auto;
padding: var(--gap);
background: var(--ekko-background-primary, #fff);
color: var(--ekko-foreground-primary, #111);
}
 
.bar {
display: flex;
gap: var(--gap);
padding-bottom: var(--gap);
 
a { text-decoration: none; }
}
 
.result { font-weight: 600; }

8. Run it

Lead with ekko dev. It watches files, rebuilds the client bundle, and HMR-reloads the browser:

1
ekko dev

For a non-watch or production run, build the client once then run with explicit permissions:

1
2
ekko build --client
ekko run server.tsx --allow=fs,net,crypto

Open http://localhost:3000, type a name, and submit. The greeting comes back from the same-process API route, lands in a Mimir atom, and survives the trip to About and back. See The dev loop.

Where to go next

  • Forms, drafts in atoms, submission state, and a no-JS fallback.
  • Authentication, sessions, req.user, and the crypto grant in

practice.