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:
app!;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:
async (soda_server::axum is the axum it serves with.)
Local-first:
app!;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):
app!;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.
| Folder | Address | Params field |
|---|---|---|
src/routes/ | / | |
shelves/ | /shelves | |
shelves/[shelf]/ | /shelves/rice | shelf: String |
docs/[[page]]/ | /docs, /docs/intro | page: Option<String> |
files/[...path]/ | /files/a/b | path: Vec<String> |
(admin)/settings/ | /settings (a group: not in the address) |
| File | What it is |
|---|---|
page.rs | The 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.rs | Around 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.rs | What its forms and buttons post to: #[action]s |
downloads.rs | What its pages offer to save: #[download]s |
not_found.rs | A page for what isn't there under it (.ok_or(NotFound)?, an unknown address) |
error.rs | A 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.state | What the server keeps (the App app! names) |
cx.session | The 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.app | The app's state for the open space |
cx.space | The space: its name, members, creator |
cx.me | Who's looking: id, name, can_edit (show controls that change things only when it holds), owner |
And on either:
cx.params | The route's params |
cx.page | The 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.rec | The engine's state and the space's record, for what the rest doesn't cover (soda_local::ui::rev(cx.rec, target)) |
view!#
view! - 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, aView, anOptionof one, or an iterator of them.- Attributes take text, a number, a
bool(there or not;aria-*says"true"/"false") or anOption. A value with a<or>in it (a turbofish, a comparison) goes in(…)or{…}:hidden=(n > 0).hreftakes anHref, a download (href=StockList), or text. class="…"is literal, so Tailwind finds every class;class:name=condadds one when it holds (any class:class:bg-accent/10=selected);class=expradds aClass, text, or anOptionof one. They're joined into one attribute bycn, 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 aHandler::raw(…).bind:namebinds a signal;show="$open"shows and hides.query:nameon 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}isitem=item, and a barecompactiscompact=true.
Its errors point at the attribute, and say what to write instead.
Components#
#[component] makes a function of its props into a component:
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.
| Component | Props | Draws |
|---|---|---|
Button | primary, quiet, danger, small, submit, cancel (closes what it's in: an edit in place, a dialog), disabled | <button class="soda-btn …"> |
Input | name, kind (its type: "date", "email"…), value, placeholder, required, autofocus, maxlength, autocomplete | <input class="soda-input"> |
Textarea | name, value, placeholder, rows (4), required, autofocus, maxlength | <textarea class="soda-input"> |
Select | name, required; its <option>s inside | <select class="soda-input"> |
Field | label, name, hint; its control inside | A label, the control, its hint, and the error an action said about it |
Card, Chip | A bordered surface; a small rounded label | |
Time | at (ms), format (When::Ago, Short, Clock, Date, Full, Stamp) | A moment in the reader's own language and zone |
Date | day (a Day) | A day in the reader's own language, the same day wherever they are |
Clock | since, until | How long since, ticking |
Countdown | from, due, met, until, compact; what it says before it's drawn inside | A deadline, ticking: left, over, met; :state(soon | overdue | met | late) for what's around it |
Tabs, Tab | label, pills; a Tab's href (a Link, from cx.link(…) or cx.choice(…).link(…), or any Href) and current | Links to views of one thing, the current one aria-current="page" |
Dialog | heading, open, close (the page without it), modal, locked | A sheet, open while the address says so; Escape, ✕, a click beside it, <Button cancel> and its form going through close it, Back too |
Menu, MenuItem | id, label, button (its face, if not the label), button_class; an item's href, or on:click, and current | A popover of choices beside its button, closed as one is chosen |
EditInPlace | current-<field>=… to fill a field as it opens | A <details> whose summary opens the form inside; Escape and Cancel close it, and the keyboard goes back |
Lists, List, ListItem | moves (an action taking a Moved), disabled; a list's id, label, head, foot, items_class; an item's id, label | Items dragged between lists, or moved with Alt and the arrows |
History | stops (cx.history()), at (cx.looking_back()) | The space's history as a line to ride back along |
Empty | title; what goes here, and the one thing to do, inside | What a list or a page says while there's nothing in it |
Avatar | name, account | Someone's initials in their own color, the same on every device |
Actions#
// src/routes/shelves/[shelf]/actions.rs
use Params;
use crate*;
pub async #[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.state | What the server keeps: an action changes it however the app keeps it (its own functions, a database) |
cx.session | The 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.params | The 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).
| Type | Reads | When empty |
|---|---|---|
String | Text, trimmed | "Required." |
Option<String> | Text, trimmed | None |
bool | A checkbox | false |
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
// <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 check | The app's tests (with events.lock, local-first), Clippy, and the token check |
soda test | check, then the build and the end-to-end tests |
soda build | The site, in dist/, and a server's program beside it (a static site's every page, in it) |
soda deploy | Local-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.