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.
0000000000// `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.
0000000000// atoms/theme.ts — the single source of truth
import { atom } from "ekko:rune/mimir";
export const themeAtom = atom({ key: "theme", default: "dark" }); // "dark" | "light"
0000000000// 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 0000000000// 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; <ThemeProvider theme={theme}> <ThemeCssVars /> {/* mirrors `theme` onto :root as --ekko-* on the client */} }
0000000000/* 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); }
0000000000// 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.
0000000000import { 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 <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); } // 3) the dropzone — onFilesAdd (NOT onChange); files[0].preview is a data: URL 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.
0000000000import { 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?
0000000000import { connect, defineTable, col } from "ekko:db/orm";
const Note = defineTable("notes", {
id: col.int().primaryKey().autoIncrement(), 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.
0000000000app.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"); });
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.
0000000000/* START SSR */
import { connect } from "ekko:db/orm"; // server-only — stays out of the client bundle
const db = connect("./app.db");
/* END SSR */