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 , 6. API routes

So far notes live only in the browser, a reload (without the session) loses new ones, and they are not shared. Let us add a tiny API in the same app to persist notes server-side, then have the client read and write through it.

A server-side store

For the tutorial, keep notes in a JSON file (the runtime's ekko:fs writes it, you granted fs):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// lib/store.ts
import { readText, writeText, exists } from "ekko:fs";
import type { Note } from "./notes";
import { seedNotes } from "./notes";
 
const FILE = "data/notes.json";
 
export function allNotes(): Note[] {
try { return JSON.parse(readText(FILE)); }
catch { return seedNotes; }
}
export function saveNotes(notes: Note[]) {
writeText(FILE, JSON.stringify(notes, null, 2));
}

Granting fs lets the app read and write files. Scope it in production (fs: ["./data/**", ...]) so the app can only touch what it needs, see Permissions.

The API routes

Register them on the same app. Handlers get (req, res); returning a value auto-sends it (objects as JSON):

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
// server.tsx
import { allNotes, saveNotes } from "./lib/store";
 
app.api("GET", "/api/notes", () => allNotes());
 
app.api("GET", "/api/notes/:id", async (req) => {
const note = allNotes().find(n => n.id === req.params.id);
return note || { error: "not found" };
});
 
app.api("POST", "/api/notes", async (req) => {
const { title, body } = await req.json();
const id = String(title || "note").toLowerCase().replace(/\s+/g, "-") || String(Date.now());
const notes = allNotes();
notes.push({ id, title: title || "Untitled", body: body || "" });
saveNotes(notes);
app.invalidate("/"); // the cached list page is now stale → re-render it
return { id };
});
 
app.api("PUT", "/api/notes/:id", async (req) => {
const notes = allNotes();
const i = notes.findIndex(n => n.id === req.params.id);
if (i === -1) return { error: "not found" };
notes[i] = { ...notes[i], ...await req.json(), id: notes[i].id };
saveNotes(notes);
app.invalidate("/");
return notes[i];
});

req gives you params (from :id), query (the raw query string, parse as needed), body (the raw request body string, call await req.json() to parse it), and user (if middleware set it). res has json, send, status, and header. See Request & response.

The client uses the API

Replace the local-only writes with fetches. Load the notes into the atom on mount, and POST on save:

1
2
3
4
5
6
7
8
9
10
// pages/index.tsx , load from the API after hydration
import { useEffect } from "@ekko/react";
import { useAtom } from "ekko:rune/mimir";
import { notesAtom } from "../atoms/notes";
 
export default function Home() {
const [notes, setNotes] = useAtom(notesAtom);
useEffect(() => { fetch("/api/notes").then(r => r.json()).then(setNotes); }, []);
// ... render `notes` ...
}
1
2
3
4
5
6
7
8
9
10
// pages/notes/new.tsx , POST then navigate
async function save() {
const res = await fetch("/api/notes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, body }),
});
const { id } = await res.json();
navigate(`/notes/${id}`);
}

The server persists to data/notes.json, invalidates the cached list page, and the next render shows the new note, to every visitor, surviving restarts.

405 for free

When you declare GET and POST on /api/notes, rune automatically answers other methods (PUT/DELETE/...) to that path with 405 Method Not Allowed and an allowed list. You do not write that handler. See Validation & options.

One app, no second server

The key point: the API and the pages are the same process. allNotes() is a function call, not an HTTP hop; the API and the SSR render read the same data; and you invalidate the page cache from the API handler directly. No CORS, no separate deploy, no type boundary.

Next: a proper theme and polish, 7. Styling.