Reference

Soda reference

What an app on Soda writes, in one place. tutorial.md builds an app on a server, local-first.md one local-first; framework.md is the design and the reasons.

The app#

An app names its backend's app! in src/main.rs. On a server:

mod book;

soda_server::app!(book::Book);

app! names what the server keeps (a soda_server::App: any Send + Sync type, made once by its async fn start() -> Result<Self, Box<dyn Error + Send + Sync>>, whose error stops the server saying why) and writes the program: the router read from src/routes, the typed links (link::…), the app's own prelude (crate::prelude::*: Soda's, with the app's Cx and Act), and main, which serves on SODA_ADDR (127.0.0.1:3000 without it) with the built site from dist/ (SODA_DIST) and the app's data folder (SODA_DATA, data/ without it): soda_server::data(), where an app keeps its database's file, and the server the key it seals sessions with. The shell is web/app.html; #app is drawn between its <!--app--> and <!--/app-->.

A server app's own routes, beside its pages (a webhook, a feed, a health check), are axum's, with its state, from App::routes; a handler that changes what pages show takes Pages:

impl App for Book {
    async fn start() -> Self { Book::default() }
    fn routes() -> Router<Arc<Self>> {
        Router::new().route("/health", get(|| async { "ok" })).route("/hook", post(hook))
    }
}
async fn hook(State(book): State<Arc<Book>>, pages: Pages) -> &'static str {
    book.sign(/* … */);
    pages.notify(["book"]); // or pages.changed(): every open page
    "ok"
}

(soda_server::axum is the axum it serves with.)

Local-first:

mod components;
mod model;

soda_local::app!(model::Stock, catching_up = "Catching up on the pantry…");

Here app! names the model (a soda_local::App) and writes the program the same way. Its settings are catching_up (what the page says while it catches up on what changed while it was away), csp (the page's own Content-Security-Policy), and bar = false: the root layout places Soda's bar itself, in parts (cx.bar(…)), in a header or a rail of its own, and the notices (cx.notices()), above its content, say.

Static, every page drawn once as the site is built (static.md):

mod handbook;

soda_static::app!(handbook::Handbook, url = "https://handbook.example");

Here app! names what the pages are drawn from (a soda_static::App: any Send + Sync type, made by async fn load() -> Result<Self, Box<dyn Error + Send + Sync>>, read from the site's folder, and const WATCH: &[&str], the folders it's read from, which soda dev watches) and writes the program: with --build <dist> it draws every page into dist/; without, it serves them as a server would (soda dev runs it so). Its one setting is url, where the site is served: each page then says its own address (<link rel="canonical">, og:url), and the build writes sitemap.xml. A static site has no actions (a route with an #[action] is a compile error saying so), and a download is built once, at its address, so it takes only its cx.

build.rs is soda_build::routes(): it has Cargo compile again when a route is added or removed.

Routes#

A folder under src/routes is an address; its files say what's there.

FolderAddressParams field
src/routes//
shelves//shelves
shelves/[shelf]//shelves/riceshelf: String
docs/[[page]]//docs, /docs/intropage: Option<String>
files/[...path]//files/a/bpath: Vec<String>
(admin)/settings//settings (a group: not in the address)
FileWhat it is
page.rsThe page: pub fn page(cx: &Cx<Params>) -> View (or -> Result<View>), pub fn head(cx) -> Head (Head::title("…").description("…")), and on a static site with params pub fn params(cx: &Cx) -> Vec<Params>, the pages to build
layout.rsAround every page under it: pub fn layout(cx: &Cx<Params>, children: View) -> View; the root's has pub fn intro() -> View too, the start screen's words (an element marked slot="header" goes across its top, one marked slot="aside" beside its forms)
actions.rsWhat its forms and buttons post to: #[action]s
downloads.rsWhat its pages offer to save: #[download]s
not_found.rsA page for what isn't there under it (.ok_or(NotFound)?, an unknown address)
error.rsA page for any other error under it

A page's head goes in its shell's head whenever the page is drawn whole (a server's first paint, a page drawn at build time): its title in <title>, after the app's name, and in og:title; its description in <meta name="description"> and og:description, each where the shell has the tag. A page that isn't there (not_found.rs) is answered with a 404, and one that went wrong with a 500, on a server.

A page that needs no space, on the local engine (a privacy page, a landing page), is #[prerender] pub fn page(cx: &Public) -> View: drawn at build time as plain HTML at its address (so a first visit, a search engine, reads it before anything has run), and by the worker for anyone, before any account screen or the start screen. Public is a Cx that reads only its address; its head takes one too. It's at one address (no params).

A folder's files are its module, named for it (routes::shelves::shelf::actions), and its params are super::Params from any of them. A route's mistakes (a missing page, a param named twice) are compile errors naming the file.

Links are typed functions of the params, in a module per folder: link::root(), link::shelves::shelf(&id). An Href takes a query: link::root().with("q", "rice").

Path or query. A path is a place: a screen, a thing's own page, a section of one, each with its own title and its own step in the history (moving to one starts at its top, or where it was, coming back, with the keyboard on its heading). The query refines the place you're on (a filter, a search, a sort), changing in place, and holds a dialog over it (?edit=…). Views of one thing that share a frame are pages in a group with a layout: (stock)/page.rs and (stock)/soon/page.rs, around both (stock)/layout.rs.

The space, on the local engine, is in the address before the path: /~3fa9c2e1b0/shelves/rice is /shelves/rice in the space whose id starts 3fa9c2e1b0. Routes, cx.page and links don't see it: an href, cx.go, Redirect and a download are written under the page's space, and so is a path given to soda.go. An address naming none opens the space opened last; /~new/ is the start screen. (A route folder can't start with ~.)

Cx: what a page reads#

What it reads beyond its address is its backend's. On a server:

cx.stateWhat the server keeps (the App app! names)
cx.sessionThe browser asking: its id (a random one), and what it remembers, in a cookie sealed with the server's key (so a server that starts again takes it back)
cx.listen("book")What this page shows, for narrowing when it's drawn again: after an action that says it changed book (cx.notify("book")), and not after one that says it changed something else. A page that never calls it is drawn again after every action

A server's page, layout, head or download may be async (one reading a database): pub async fn page(cx: &Cx) -> View, awaited by the router, its Cx the same as any page's.

Local-first:

cx.appThe app's state for the open space
cx.spaceThe space: its name, members, creator
cx.meWho's looking: id, name, can_edit (show controls that change things only when it holds), owner

And on either:

cx.paramsThe route's params
cx.pageThe whole address, with its query
cx.query("q")One value of the query: anyone's link can say anything, so check it
cx.query_as::<T>()The whole query, read into a type with every field optional
cx.link(href), cx.link_within(href)A link that says whether it's this page (or, within, this page or one under it), for <Tab>s and <MenuItem>s between pages
cx.choice("show", &["todo", "done"])A filter kept in the query: .value(), .is(Some("done")), and .link(option) for a <Tab>
cx.filters(&["who", "q"])The page's filters: .get("who"), .any(), and .cleared() (the page without them, "Show everything"). This device remembers them
cx.as_left(href)A link to a page as this device left it, with the filters it last had there (a rail's links), current on the page and under it
cx.recall("key")What this device (local-first, sealed with its own key) or this session (on a server) remembers under key, an action's cx.remember: a warning put away, a name to fill in
cx.bar("status people")Local-first: some of Soda's bar, for a layout that places it itself: spaces, name, people, status, undo, share, security, in the order named
cx.sheet()Local-first: which of Soda's sheets the page has open (Some(Sheet::Share)), for the app's own button for it to show it's pressed
cx.notices()Local-first: what the space and this device have to say (being invited or removed, changes the store won't take, a clock that's off…), for a layout without the bar; without the passkey suggestion, which cx.passkey_nudge() is
cx.here_with(&[("edit", Some(id))])This page with its query changed (None takes a key away)
cx.name_of(id)Someone's name as the space shows it
cx.now(), cx.today()The time (ms) and the day, by this device's clock (UTC). A page that reads them is drawn again as the minute changes
cx.history(), cx.looking_back()The space's history line, for <History>
cx.engine, cx.recThe engine's state and the space's record, for what the rest doesn't cover (soda_local::ui::rev(cx.rec, target))

view!#

view! {
    <li class="thing flex gap-3" class:opacity-60=item.done id=format!("thing-{}", item.id)>
        <a href=link::shelves::shelf(&item.shelf)>{&item.name}</a>
        {item.use_by.map(|day| view! { <Date {day} /> })}
        {cx.me.can_edit.then(|| view! { <Button quiet on:click=UseUp(item.id.clone())>"Used up"</Button> })}
    </li>
}
  • Text is in quotes, and everything is escaped. An element whose whole content comes from data is isolated (dir="auto"), so a name typed right to left can't turn the words around it.
  • {…} takes text, numbers, a View, an Option of one, or an iterator of them.
  • Attributes take text, a number, a bool (there or not; aria-* says "true"/"false") or an Option. A value with a < or > in it (a turbofish, a comparison) goes in (…) or {…}: hidden=(n > 0). href takes an Href, a download (href=StockList), or text.
  • class="…" is literal, so Tailwind finds every class; class:name=cond adds one when it holds (any class: class:bg-accent/10=selected); class=expr adds a Class, text, or an Option of one. They're joined into one attribute by cn, a class given later winning.
  • on:event=Action(…) posts to an action; on:event="…" is a Datastar expression, written in the view. Nothing else becomes code: a Datastar attribute written out takes only text in quotes, and an expression built in code is a Handler::raw(…).
  • bind:name binds a signal; show="$open" shows and hides.
  • query:name on a field makes its value the page's query: <input type="search" query:q value=f.get("q")>. Typing moves the address in place (Back doesn't step through every letter), choosing in a <select> moves it a step, and the field follows the address when it changes some other way.
  • <form action=AddShelf> posts the form to an action (§ Actions).
  • Components are capitalized: <ItemRow {cx} {item} compact />. {item} is item=item, and a bare compact is compact=true.

Its errors point at the attribute, and say what to write instead.

Components#

#[component] makes a function of its props into a component:

#[component]
pub fn ItemRow(cx: &Cx, item: &Item, compact: bool, class: Class, attrs: Attrs, children: View) -> View {
    view! { <li class="flex gap-3 py-3" class:py-1=compact class=class {attrs}>{&item.name}{children}</li> }
}

A cx: &Cx prop is whichever page's the component is drawn in, whatever that page's params: a component reads the page (cx.app, cx.me, cx.link(…)), never its route's params, so it's drawn the same in any page (<Person {cx} id=&aid />).

Every prop is required unless it's a bool (false), an Option (it takes a value, Some of one or None), class: Class, attrs: Attrs or children: View (empty), or marked #[prop(default)] / #[prop(default = …)]. A missing prop is a compile error naming it. String props take anything that converts; #[prop(into)] does that for any type. An attribute named with - or : given to a component (aria-label, on:click, data-x) goes to its attrs, which it writes with {attrs}; attr:name=… sends any other there.

The SDK's#

Each draws Soda's own classes (styling.md) on the app's tokens, and a class given to one wins over its own.

ComponentPropsDraws
Buttonprimary, quiet, danger, small, submit, cancel (closes what it's in: an edit in place, a dialog), disabled<button class="soda-btn …">
Inputname, kind (its type: "date", "email"…), value, placeholder, required, autofocus, maxlength, autocomplete<input class="soda-input">
Textareaname, value, placeholder, rows (4), required, autofocus, maxlength<textarea class="soda-input">
Selectname, required; its <option>s inside<select class="soda-input">
Fieldlabel, name, hint; its control insideA label, the control, its hint, and the error an action said about it
Card, ChipA bordered surface; a small rounded label
Timeat (ms), format (When::Ago, Short, Clock, Date, Full, Stamp)A moment in the reader's own language and zone
Dateday (a Day)A day in the reader's own language, the same day wherever they are
Clocksince, untilHow long since, ticking
Countdownfrom, due, met, until, compact; what it says before it's drawn insideA deadline, ticking: left, over, met; :state(soon | overdue | met | late) for what's around it
Tabs, Tablabel, pills; a Tab's href (a Link, from cx.link(…) or cx.choice(…).link(…), or any Href) and currentLinks to views of one thing, the current one aria-current="page"
Dialogheading, open, close (the page without it), modal, lockedA sheet, open while the address says so; Escape, ✕, a click beside it, <Button cancel> and its form going through close it, Back too
Menu, MenuItemid, label, button (its face, if not the label), button_class; an item's href, or on:click, and currentA popover of choices beside its button, closed as one is chosen
EditInPlacecurrent-<field>=… to fill a field as it opensA <details> whose summary opens the form inside; Escape and Cancel close it, and the keyboard goes back
Lists, List, ListItemmoves (an action taking a Moved), disabled; a list's id, label, head, foot, items_class; an item's id, labelItems dragged between lists, or moved with Alt and the arrows
Historystops (cx.history()), at (cx.looking_back())The space's history as a line to ride back along
Emptytitle; what goes here, and the one thing to do, insideWhat a list or a page says while there's nothing in it
Avatarname, accountSomeone's initials in their own color, the same on every device

Actions#

// src/routes/shelves/[shelf]/actions.rs
use super::Params;
use crate::prelude::*;

#[derive(Form)]
pub struct ShelfName {
    #[form(max = MAX_NAME)]
    pub name: String,
    pub rev: Option<u64>,
}

#[action]
pub async fn rename(cx: &mut Act<Params>, form: ShelfName) -> Result {
    let command = PantryCommand::RenameShelf { id: cx.params.shelf.clone(), name: form.name };
    match form.rev {
        Some(rev) => cx.act_seen(command, rev).await?,
        None => cx.act(command).await?,
    }
    Ok(())
}

#[action] makes a value naming the function, in CamelCase (Rename, AddItem(shelf.id.clone())), for <form action=…> and on:click=…. Its address is its route's, filled in from the page it's on, so nobody writes one. An argument named form is read from the form that posted; the others are given in the view, owned (String, not &str), and an argument of type Moved comes from a <Lists>.

On cx: &mut Act<Params>, on a server
cx.stateWhat the server keeps: an action changes it however the app keeps it (its own functions, a database)
cx.sessionThe session that posted: its id
cx.notify("book")What it changed: only the pages listening for it (cx.listen("book")) are drawn again, with this session's own. Without it, every open page is
On cx: &mut Act<Params>, local-first
cx.act(command).await?The model decides it; the events are signed, sealed and sent. Its refusal is the Err
cx.act_seen(command, rev).await?The same, against what the form showed: a change someone made meanwhile comes first, and its person is asked
cx.read(|state| …)Reads the engine's state
cx.say_with_undo("Gave the 8 AM metformin.")Says what was just done, with Undo beside it while it's this device's to undo
On either
cx.say("…")Says something where its person's pages show it (a toast)
cx.remember("key", value), cx.forget("key")What this device (sealed, kept across restarts) or this session (on a server, while it runs) remembers for its person (cx.recall)
cx.go(link)Moves the page that posted, once it's done
cx.keep(&["field"])Leaves these fields as they were when the form is emptied for the next
cx.keep_form()Leaves the whole form as it is (and an open dialog open)
cx.clear(&["signal"])Empties bound signals
cx.paramsThe route's params

An action value also says where it posts, for an element that posts it itself (<ir-checkin action=CheckIn.url()>), and posts with bound signals' values, which the action reads as its form's fields: on:click=PostMessage.with_signals(&["message"]), then cx.clear(&["message"]). A control whose value is the action's (a select that assigns someone) is a one-field form that submits as it changes: <form action=Assign(id) class="contents"><select name="role" on:change="el.form.requestSubmit()">…</select></form>, with cx.keep_form() in the action. on:click=Sheet::Share (or Sheet::Security) opens Soda's own sheet, in the page's address as an app's dialog is (?sheet=share, which Back closes), and Switch::To(id) (or Switch::New) opens another of this account's spaces (or the screen that starts one) in this page; href=cx.space_link(&id) is a link to one, which a new tab can open too. A Handler prop takes an action as on: does: <Said ack=Acknowledge(id) />.

An action returns Result: NotFound, a refusal (Err("…".into()), or the engine's), FieldErrors (FieldErrors::one("date", "That's in the past.")), each shown where it belongs, or Redirect::to(link) (the page that posted goes there).

A page that's moved returns Err(Redirect::to(link::…).into()): the page moves there in place, the old address left out of its history, so an old link keeps working. A download that's moved answers with a redirect too.

Forms#

#[derive(Form)] reads a struct's fields from what was posted, each field's error shown under it (by a <Field> of its name, or the browser's own bubble).

TypeReadsWhen empty
StringText, trimmed"Required."
Option<String>Text, trimmedNone
boolA checkboxfalse
numbers, Option<…>A number"Required." / None
Day, Option<Day>A date (2026-10-20)"Pick a date." / None
Vec<String>Every value of a name (checkboxes, a multiple select)[]
any FromStr with #[form(parse)]Its own way"Required."

Rules: #[form(max = 80)], min, name = "how-many" (the posted name), trim = false, and message = "…" (said instead of the rule's own words).

Downloads#

// src/routes/downloads.rs
#[download]
pub fn stock_list(cx: &Cx) -> Result<Download> {
    Ok(Download::text("pantry.txt", "text/plain", "…"))
}
// <a href=StockList download>"Download the list"</a>

Made from what the page reads as it's asked for (by the instance, from what's on the device, or by the server, which may await it: pub async fn), at its route's address under /api/_; arguments given in the view (href=Receipt(order.id.clone())) are in its query. Download::new(name, mime, bytes) for anything else. Download::page(name, html) is a whole page shown in place, to read or print (<a href=PrintList target="_blank">): its styles inline, its one script, if any, a button's onclick="print()".

Days#

soda_local::Day (in the prelude as Day) is a calendar day: "2026-10-20".parse(), Day::new(2026, 10, 20), Day::of(ms), day.until(other) (days), day.plus(n), day.start() and day.end() (ms, UTC). It's written as 2026-10-20 in forms, JSON and signed history.

The CLI#

soda new <dir> ["<Name>"]A new app on a server, from templates/server, called what its folder is in words (family-pantry is Family Pantry) unless it's named
soda new --local <dir> ["<Name>"] [--noun pantry] [--plural …]A new local-first app, from templates/local: --noun is what one space is called (the template's is "list"), its plural English's unless --plural says
soda new --static <dir> ["<Name>"]A new static site, from templates/static: a handbook of Markdown pages, every page drawn as it's built
soda dev [--port N] [--no-hot]Serves the app, patching each change into the running server or instance (hot-patching.md)
soda checkThe app's tests (with events.lock, local-first), Clippy, and the token check
soda testcheck, then the build and the end-to-end tests
soda buildThe site, in dist/, and a server's program beside it (a static site's every page, in it)
soda deployLocal-first or static: a production build, published with Vercel. On a server: a container image of the program and its site, serving on 8080, its data folder a volume (--image name:tag, --push, --platform linux/amd64); --to ssh://you@host runs it on that Docker host in place of the one before, its data kept (--publish the port, 80)

Local-first builds use the store SODA_STORE_URL and SODA_STORE_KEY name, or the SDK's hosted one.

This page is docs/reference.md in the SDK, built into this site as it is.

Esc