Tutorials
Local-first: Pantry, from soda new --local to deployed
Soda apps run on one of two backends (framework.md §16). On a server (the tutorial), the app is a program that draws every page from what it keeps. Local-first, it has no server of its own: each person's browser runs the app's instance (its backend, in Rust, compiled to WebAssembly) in a service worker, and the page talks to it over requests that never leave the device.
What that gives: changes are signed by the person who made them, sealed so only the people in the space can read them, and passed along through a store that can't read a word; the app works with no network, and opens at once; accounts, passkeys, devices, sharing, undo, presence and Soda's own screens for them come with it; the host serves static files. What it asks: the app's rules are a model (commands decided into events, folded into state, the same on every device), every person's device checks every change against them, and nothing in the app is secret, since it runs where its people do.
This builds a real app that way: Pantry, what's in the cupboard, kept with the people you live
with. It has shelves, what's on each, and when each thing is best used by; things are dragged from
shelf to shelf, edited in a dialog, and what to use first is a tab away. The finished app is in
examples/pantry, kept building and passing its tests with the SDK: when this
page shows part of a file, the whole of it is there.
The pages are written as on any backend: a folder under src/routes is an address, a page.rs
draws it, forms post to actions written beside it, and markup is JSX-like (view!). If you've
done the tutorial, only the model, and what it lets the SDK do, is new.
What you need: Rust (with rustup: each app pins its toolchain), Node 22, and, for a store of
your own while you work, Docker and the Supabase CLI.
1. A new app#
Install the SDK's CLI from a checkout of it, once:
Then make the app, local-first. It's called what its folder is (Pantry: what people see, and what
passkeys are saved under; a second word after the folder names it otherwise), and --noun is what
one space is called on Soda's own screens ("Your pantries", "Share this pantry"; the plural is
English's, and --plural is for a word that breaks the rules):
It starts as the SDK's local-first template, a shared checklist that builds and passes its tests as it is. Start
a store for it to use while you work (from the SDK checkout: supabase start, and supabase status
prints its key), then serve it:
Open the address it prints, make a list, and add to it. Now change something in
src/routes/layout.rs (the words under "Checklist", say) and save: the open page shows it in about a
second, without reloading, and the list is still there. That's soda dev patching the change into
the running instance (hot-patching.md). A change to web/app.css is in the page
in a fifth of a second; a mistake is shown over the page, with where it is, until it's fixed.
What's there:
| Where | What |
|---|---|
src/model.rs | The rules: the state, its events, and how commands turn into events |
src/routes/ | The pages: a folder is an address, page.rs draws it, actions.rs is what its forms post |
src/components.rs | What the pages share, as #[component]s |
src/main.rs | The program: soda_local::app! names the model |
web/app.html, web/app.css | The page shell, and the look: design tokens everything is drawn with |
tests/e2e.mjs | Real browsers, two people at once, end to end |
2. The model#
Everything Pantry knows is in src/model.rs, as plain data and pure functions: no pages, no I/O.
Every member's instance replays the same signed events in the same order, so every one of them
reaches the same pantry.
The state is shelves and what's on them:
(Day is a calendar day, soda_local::Day: see §8.) A space in Soda is a Space<A>, the app's state
beside the SDK's own (its name, its members, who made it), so Pantry names one:
pub type Pantry = ;Events are what's signed and kept: each change anyone makes is one or more of them, forever.
Because every later version of the app has to read every event ever signed, the way they're written
may only grow: new events, new variants, new fields that may be missing, never a rename. cargo test keeps events.lock (one line per field) and fails if the code would change one that's there.
Commands are what a person asks for; decide turns one into events, or refuses it in words the
person can act on:
evolve folds an event into the state. It's the rule that matters most: events come from
everyone, including a device that skipped decide (a modified one, or an old version), so it
checks everything again and quietly ignores what decide would have refused. It never panics and
never trusts a number:
ShelfRemoved =>
ItemMoved => describe says each event in the past tense, for the pantry's history ("Ann put “Rice” on
“Cupboard”"). The app's lints (top of src/main.rs) make every way to panic an error, since a
panic stops that person's instance.
The model's tests run it like the engine does, and the SDK attacks it with what a hostile member's
device could sign instead (soda_local::check):
Replace the template's src/model.rs with Pantry's, and point src/main.rs at it:
app!;soda check runs the tests, Clippy and the stylesheet's token check.
3. The first page#
src/routes/page.rs is /. A page is a function of its Cx (everything a page reads: cx.app
is the state, cx.space the space, cx.me who's looking) returning a View; head names the tab:
use crate*;
view! is markup the way JSX writes it. Text is in quotes and always escaped; {…} takes anything
that renders (text, numbers, an Option, an iterator of views); class: adds a class when it holds
(class:line-through=item.done). Show controls that change things only when cx.me.can_edit: the
engine refuses changes otherwise anyway (someone looking back in the history, someone removed).
A component is a function too, its arguments its props; {cx} is cx=cx, and a bare pills is
pills=true:
/// A shelf and what's on it, in order.
The SDK's own components (Button, Input, Field, Tabs, Dialog, Menu, Empty…, in the
reference) come with the prelude. They're drawn with Soda's classes on the app's
tokens, and a class given to one wins over its own: <Button class="rounded-pill">.
soda dev patches a new page in as it's saved, like an edit: no reload.
4. Forms and actions#
A form posts to an action: a function beside the page, which #[action] turns into a value the
view names, in CamelCase. Its fields are read into a type #[derive(Form)] writes, with rules that
are shown under their fields when they don't hold:
// src/routes/actions.rs
use crate;
use crate*;
pub async // in a component
<form action=AddShelf class="new-shelf flex gap-2">
<Input name="name" placeholder="A shelf: the fridge, the freezer…" maxlength=MAX_NAME required />
<Button submit>"Add the shelf"</Button>
</form>cx.act hands the command to the engine: decide turns it into events, which are signed, sealed,
sent, and folded in, and every open page draws again. Its refusal comes back as the Err, and
? shows it to its person. A new id is made here (new_id()), so decide stays a pure function.
When an action goes through, its form is emptied for the next, but for the fields it keeps: adding a thing keeps its use-by date, since the next thing added is likely to keep as long.
pub async An argument that isn't form is the action's own, given in the view: AddItem(shelf.id.clone()),
on:click=UseUp(item.id.clone()). cx.say("Used up “Rice”.") says something where every page
shows it, with Undo beside it.
5. Dragging between shelves#
<Lists> holds lists whose items are dragged between them, or moved with Alt and the arrow keys,
with nothing moving until the model has moved it. Each shelf is a <List>, each thing a
<ListItem>, and the move goes to an action taking a Moved:
pub async <Lists moves=MoveItem disabled=!cx.me.can_edit>
</Lists>moves=MoveItem is checked by the compiler: only an action taking a Moved fits. What's marked
data-drag-hide (a thing's buttons) stays behind as it's dragged, and buttons inside an item never
start a drag.
6. What the address says#
What a page is showing is in its address, never in the instance: a reload keeps it, Back undoes it, and a link sent to someone opens it there. Which part of the address says what:
- A path is a place: a screen, a thing's own page, a section of one. Moving to one is a page move: its own title, its own place in the history, the top of the page (or where it was, coming back), and the keyboard on its heading.
- The query refines the place you're on: a filter, a search, a sort (
?q=rice). It changes in place, and nothing jumps. - A dialog over the page you're on is in the query too (
?edit=…): it belongs to whatever page opened it.
Pantry has two views of what's in it: every shelf, and what to use soon. Each is a place, with its
own content and title, so each is a page: / and /soon. What they share (the tabs between them,
the download, the edit dialog) is a layout around both. A folder in parentheses is a group: its
pages are at its parent's address, so (stock)/page.rs is /, and (stock)/layout.rs wraps its
pages and no others (a shelf's own page, /shelves/…, isn't in it).
src/routes/
layout.rs around everything: the history line (and the start screen's words)
(stock)/
layout.rs the tabs, the download, the edit dialog
page.rs / every shelf
soon/page.rs /soon what to use soon
shelves/[shelf]/ /shelves/:shelfTabs between pages are links; cx.link makes one that says whether it's this page:
// src/routes/(stock)/layout.rs
(cx.link_within is current under it too, for a section's link: /incidents while one of them is
open.) Where tabs only filter one list (the template's All, To do and Done), they're the query's:
cx.choice("show", &["todo", "done"]) reads it, keeping only what's an option (anyone's link can
say anything), and .link(Some("done")) makes each option's link.
A dialog is open while the address says so. A thing's name links to this page with ?edit= and
its id; the dialog closes to the page without it, whichever of the two it was opened over. Escape, ✕ and a click beside it close it, and so
does its form going through; closing steps back to where its link opened it, so Back closes it too:
let editing = cx.query.and_then;
view! The dialog's form edits against the thing as it showed it. If someone else changed it meanwhile, theirs isn't overwritten unseen: this person is told, and can apply theirs anyway. The form carries the thing's revision, and the action makes the change against it:
let rev = rev;
// <input type="hidden" name="rev" value=rev> in the form, and in the action:
match form.rev What item:…/details means is the model's to say (§10).
The space is in the address too, before the path: /~3fa9c2e1b0/soon is /soon in the
pantry whose id starts 3fa9c2e1b0. Pantry never sees it. Its routes read /soon, and its links,
moves and downloads are written under the page's pantry for it. So two tabs can show two pantries,
each acting on its own, and a reload keeps each. An address that names none (/soon, typed in)
opens the pantry opened last, and is put under it; /~new/ is the screen that starts one. Soda's
menu of pantries is links, so one opens in a new tab too; a layout with its own menu links to each
with cx.space_link(&id).
7. A page of its own#
A folder in brackets is a param: src/routes/shelves/[shelf]/page.rs is /shelves/:shelf. Its
Params (cx.params.shelf) come from the address, and its link is typed:
link::shelves::shelf(&shelf.id). What isn't there returns NotFound, and the nearest
not_found.rs shows:
A shelf's name is changed where it is: <EditInPlace> is a <details> whose summary opens the form
inside. It opens as a snapshot of the shelf (current-name= fills the field), so what's typed
survives others' changes; Escape and <Button cancel> close it, and the keyboard goes back to the
summary.
<EditInPlace class="group" current-name=&shelf.name current-rev=rev>
<summary class="group-open:hidden"><h1></h1></summary>
<form action=Rename>
<input type="hidden" name="rev" value=rev>
<Input name="name" value=shelf.name.clone required />
<Button primary submit>"Rename"</Button>
<Button quiet cancel>"Cancel"</Button>
</form>
</EditInPlace>The folder's own actions.rs posts to an address under the shelf, so its actions read
cx.params.shelf too, and cx.go(link::root()) moves the page once a shelf is removed. On the
pantry's page, each shelf's <Menu> opens the shelf, or removes it.
8. Days#
A use-by date is a day, not a moment: the 20th is the 20th wherever its reader is. soda_local::Day is
one, written as <input type="date"> writes it (2026-10-20) in a form, in JSON and in signed
history, and a form field of its own (Option<Day> for one that may be empty):
<Date day=… /> says it in the reader's own language ("Tue, Oct 20"), and cx.today() is today
(in UTC: the instance doesn't know its reader's zone). Whether a thing is to use soon is the
model's:
The page draws again whenever the pantry changes, not as time passes: what has to tick is a
<Countdown> or a <Clock>.
9. A download#
What a page offers to save is made by the instance, from what's on this device, as it's asked for:
// src/routes/downloads.rs
// in a view
<a href=StockList download>"Download the list"</a>10. Undo, and edits that collide#
The SDK's bar has undo and redo (⌘Z, ⇧⌘Z), for changes this device made. The model says what each
change did (step, from the pantry just before it) and how to go back or forward (travel), only
if what it touched is still as it left it: someone else's change since always stands.
// Used up, undone: back where it was, as it was.
=> match stock.items.get ,travel forward is also how a change that reached everyone late (a phone offline for hours) is made
again, from the pantry as it is now.
target says what an edit is to, as a path: two people changing the same thing at once never
silently overwrite each other. The one that reaches everyone second doesn't count, and its person
is asked whether to apply theirs anyway. A thing's name and date are one edit, its place another;
using it up covers both. A thing added to a shelf is under that shelf, so it and the shelf's
removal can't both count:
11. The look#
web/app.css is the look. Every color, font, radius and shadow is a design token in its @theme,
and everything is drawn with them, Soda's own screens too: change a token, and the whole app
follows. Pantry's is a kitchen's sage green on warm paper:
@}Views use Tailwind's utilities on those tokens (bg-surface, text-muted, rounded-card), and
the build refuses anything else (a raw #ff0000, a utility the stylesheet doesn't have), so the
look stays one look. styling.md has the tokens, Soda's classes, and how its own
screens are restyled.
12. Testing#
tests/e2e.mjs serves dist/ as the host will, and drives two people in real browsers, each with
a passkey of their own: Ada makes the pantry, stocks it, drags things, edits one in its dialog;
Bob opens her link on a phone, joins, and each sees the other's changes. Read Pantry's for the
patterns (waiting on what the page shows, never on time).
13. Deploying#
A deployed app depends on the SDK by its repository, so Cargo.lock pins the commit every build
uses. In Cargo.toml, swap the paths soda new wrote for the repository (the comment there has
the lines), then cargo update -p soda-local. With the Vercel CLI linked to a project of the app's
(vercel link):
builds the production instance on this machine and publishes it, dist/ and vercel.json (its
rewrites send every page's address to the shell, and its headers set the page's
Content-Security-Policy). Without SODA_STORE_URL, a build uses the SDK's hosted store, which
asks a new browser for a quick check before it signs in. The app is a static site: Vercel serves
files, and every person's instance does the rest.
Where next#
- reference.md: every component,
view!, routes, actions, forms,Cx, the CLI. - framework.md: Soda's design, and why.
- building-an-app.md: more on the model (what a member may see, records, split keys, the engine's routes).
- hot-patching.md: what
soda devdoes as you save.
This page is docs/local-first.md in the SDK, built into this site as it is.