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:

cargo install --path soda/cli

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):

soda new --local pantry --noun pantry
cd pantry

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:

export SODA_STORE_URL=http://127.0.0.1:54321 SODA_STORE_KEY=<the publishable key>
soda dev

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:

WhereWhat
src/model.rsThe 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.rsWhat the pages share, as #[component]s
src/main.rsThe program: soda_local::app! names the model
web/app.html, web/app.cssThe page shell, and the look: design tokens everything is drawn with
tests/e2e.mjsReal 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:

#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
pub struct Stock {
    pub shelves: BTreeMap<String, Shelf>,
    pub items: BTreeMap<String, Item>,
}

pub struct Shelf { pub id: String, pub name: String, pub added_at: u64, pub items: Vec<String> }

pub struct Item {
    pub id: String,
    pub name: String,
    pub shelf: String,
    pub use_by: Option<Day>,
    pub added_by: String,
    pub added_at: u64,
    pub changed_by: String,
}

(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 = Space<Stock>;

Events are what's signed and kept: each change anyone makes is one or more of them, forever.

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, soda_local::Shape)]
#[serde(tag = "type")]
pub enum PantryEvent {
    ShelfAdded { id: String, name: String },
    ShelfRenamed { id: String, name: String },
    ShelfRemoved { id: String },
    ItemAdded { id: String, shelf: String, name: String, use_by: Option<Day> },
    ItemEdited { id: String, name: String, use_by: Option<Day> },
    ItemMoved { id: String, shelf: String, index: usize },
    ItemUsedUp { id: String },
}

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:

fn decide(pantry: &Pantry, _author: &str, _now: u64, command: PantryCommand) -> Result<Vec<PantryEvent>, String> {
    let stock = &pantry.app;
    let shelf = |id: &str| stock.shelves.get(id).ok_or_else(|| "That shelf was removed.".to_string());
    match command {
        PantryCommand::RemoveShelf { id } => {
            let s = shelf(&id)?;
            if !s.items.is_empty() {
                return Err(format!("Move or use up what's on “{}” first.", s.name));
            }
            Ok(vec![PantryEvent::ShelfRemoved { id }])
        }
        // …
    }
}

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:

PantryEvent::ShelfRemoved { id } => {
    if stock.shelves.get(id).is_some_and(|s| s.items.is_empty()) {
        stock.shelves.remove(id);
    }
}
PantryEvent::ItemMoved { id, shelf, index } => {
    stock.put(id, shelf, *index);   // only if both are there; the index clamped to the shelf
    // …
}

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):

#[test]
fn rules_hold_against_hostile_members() {
    soda_local::check::Check::new().max_text(MAX_NAME).run::<Stock>(vec![
        ("ann", PantryCommand::AddShelf { id: "s1".into(), name: "Fridge".into() }),
        ("bob", PantryCommand::AddItem { id: "i2".into(), shelf: "s1".into(), name: "Rice".into(), use_by: None }),
        // …
    ]);
}

Replace the template's src/model.rs with Pantry's, and point src/main.rs at it:

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

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::prelude::*;

pub fn head(cx: &Cx) -> Head {
    Head::title(&cx.space.name)
}

pub fn page(cx: &Cx) -> View {
    let shelves = cx.app.shelves();
    view! {
        {if shelves.is_empty() {
            view! {
                <Empty title="No shelves yet">
                    "Add the first one: the fridge, the cupboard, the freezer."
                    {cx.me.can_edit.then(|| view! { <NewShelfForm /> })}
                </Empty>
            }
        } else {
            view! { {shelves.iter().map(|shelf| view! { <ShelfColumn {cx} {shelf} /> })} }
        }}
    }
}

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.
#[component]
pub fn ShelfColumn(cx: &Cx, shelf: &Shelf, today: Day) -> View {
    let items = cx.app.on(shelf);
    view! {
        <section class="shelf grid gap-2 rounded-card border border-line bg-surface p-3">
            <h2 class="font-semibold">{&shelf.name}</h2>
            // …
        </section>
    }
}

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::model::{MAX_NAME, PantryCommand};
use crate::prelude::*;

#[derive(Form)]
pub struct NewShelf {
    #[form(max = MAX_NAME)]
    pub name: String,
}

#[action]
pub async fn add_shelf(cx: &mut Act, form: NewShelf) -> Result {
    cx.act(PantryCommand::AddShelf { id: new_id(), name: form.name }).await?;
    Ok(())
}
// 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.

#[action]
pub async fn add_item(cx: &mut Act, shelf: String, form: NewItem) -> Result {
    cx.act(PantryCommand::AddItem { id: new_id(), shelf, name: form.name, use_by: form.use_by }).await?;
    cx.keep(&["use_by"]);
    Ok(())
}

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:

#[action]
pub async fn move_item(cx: &mut Act, moved: Moved) -> Result {
    cx.act(PantryCommand::MoveItem { id: moved.item, shelf: moved.list, index: moved.index }).await?;
    Ok(())
}
<Lists moves=MoveItem disabled=!cx.me.can_edit>
    {shelves.iter().map(|shelf| view! {
        <List id=&shelf.id label=&shelf.name head=view! { <h2>{&shelf.name}</h2> }>
            {cx.app.on(shelf).iter().map(|item| view! {
                <ListItem id=&item.id label=&item.name>{&item.name}</ListItem>
            })}
        </List>
    })}
</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/:shelf

Tabs between pages are links; cx.link makes one that says whether it's this page:

// src/routes/(stock)/layout.rs
pub fn layout(cx: &Cx, children: View) -> View {
    view! {
        <Tabs label="Show" pills>
            <Tab href=cx.link(link::root())>"Every shelf"</Tab>
            <Tab href=cx.link(link::soon())>"Use soon"</Tab>
        </Tabs>
        {children}
        // … and the edit dialog, below
    }
}

(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("edit").and_then(|id| cx.app.items.get(id));
view! {
    <a href=cx.here_with(&[("edit", Some(&item.id))])>{&item.name}</a>
    <Dialog heading="Edit" open=editing.is_some() close=cx.here_with(&[("edit", None)])>
        {editing.map(|item| view! { <EditItemForm {cx} {item} /> })}
    </Dialog>
}

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 = soda_local::ui::rev(cx.rec, &format!("item:{}/details", item.id));
// <input type="hidden" name="rev" value=rev> in the form, and in the action:
match form.rev {
    Some(rev) => cx.act_seen(command, rev).await?,
    None => cx.act(command).await?,
}

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:

pub fn page(cx: &Cx<Params>) -> Result<View> {
    let shelf = cx.app.shelves.get(&cx.params.shelf).ok_or(NotFound)?;
    // …
}

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>{&shelf.name}</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):

#[derive(Form)]
pub struct NewItem {
    #[form(max = MAX_NAME)]
    pub name: String,
    pub use_by: Option<Day>,
}

<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:

impl Item {
    pub fn soon(&self, today: Day) -> bool {
        self.use_by.is_some_and(|day| today.until(day) <= SOON_DAYS)
    }
}

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
#[download]
pub fn stock_list(cx: &Cx) -> Result<Download> {
    let mut text = format!("{}\n", cx.space.name);
    // … every shelf and what's on it
    Ok(Download::text("pantry.txt", "text/plain", text))
}

// 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.
(Step::ItemUsedUp { item, index }, false) => match stock.items.get(&item.id) {
    None if stock.shelves.contains_key(&item.shelf) => Ok(app(vec![
        PantryEvent::ItemAdded { id: item.id.clone(), shelf: item.shelf.clone(), name: item.name.clone(), use_by: item.use_by },
        PantryEvent::ItemMoved { id: item.id.clone(), shelf: item.shelf.clone(), index: *index },
    ])),
    None => Err(Stale::because("its shelf has been removed")),
    Some(_) => Err(changed(&item.id)),
},

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:

fn target(pantry: &Pantry, _author: &str, event: &PantryEvent) -> Option<String> {
    Some(match event {
        PantryEvent::ShelfRenamed { id, .. } => format!("shelf:{id}/name"),
        PantryEvent::ItemAdded { id, shelf, .. } => format!("shelf:{shelf}/items/{id}"),
        PantryEvent::ItemEdited { id, .. } => format!("item:{id}/details"),
        PantryEvent::ItemMoved { id, .. } => format!("item:{id}/place"),
        PantryEvent::ItemUsedUp { id } => format!("item:{id}"),
        // …
    })
}

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:

@theme {
  --color-page: light-dark(oklch(0.977 0.008 95), oklch(0.165 0.01 150));
  --color-accent: light-dark(oklch(0.48 0.1 155), oklch(0.8 0.11 155));
  --radius-card: 14px;
  /* … */
}

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#

soda check   # the model's tests, soda_local::check, events.lock, Clippy, the token check
soda test    # that, then the build and tests/e2e.mjs in real browsers

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):

soda deploy

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#

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

Esc