Local-first apps

A local-first app has no server of its own. Each person's browser runs the app's backend, its instance: your Rust, compiled to WebAssembly, in a service worker. The page talks to it over requests that never leave the device.

  • Changes are signed on the device that made them and encrypted for the space's members.
  • A store syncs them without being able to read them.
  • The app works offline. Accounts, passkeys, devices, sharing, undo and presence are built in.
  • The host only serves static files.

In return, your rules are a model that every device runs and checks every change against, including changes from modified clients. Nothing in the app is secret: it runs where its people are.

Pages are written as on any backend. If you've done the tutorial, only the model is new. How the engine stays correct is in architecture.md.

Build Pantry#

Pantry tracks what's in the cupboard, shared with the people you live with. The finished app is examples/pantry; each snippet below is from it, trimmed.

You need Rust (through rustup), Node 22 for the browser tests, and the soda command (cargo install --path cli from a checkout of the SDK).

1. Make the app#

soda new --local --plain pantry --noun pantry
cd pantry
soda dev

The app is named Pantry, after its folder; passkeys are saved under that name. --noun names a space on Soda's screens ("Share this pantry"), and --plural fixes an irregular plural. --plain starts from a checklist; without it you get the Kanban board (events.md).

soda dev runs its own store, so nothing else is needed. Edit src/routes/layout.rs and save: the page updates in about a second, without a reload, and your data stays.

WhereWhat
src/model.rsThe rules: state, events, and how commands become events
src/routes/The pages, and actions.rs for what their forms post
src/components.rsShared #[component]s
src/main.rssoda_local::app!, naming the model
web/app.cssThe design tokens
tests/e2e.mjsEnd-to-end tests: two people, real browsers

2. Write the model#

src/model.rs is plain data and pure functions. Every member's instance replays the same signed events in the same order, so they all reach the same state.

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

pub type Pantry = Space<Stock>;   // your state beside the SDK's: name, members, creator

Events are signed and kept forever, so they can only grow. cargo test keeps events.lock and fails on a change that would break one.

#[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 },
}

decide turns a command 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. Events can come from a device that skipped decide, so it checks everything again, ignores what decide would refuse, and never panics:

PantryEvent::ShelfRemoved { id } => {
    if stock.shelves.get(id).is_some_and(|s| s.items.is_empty()) {
        stock.shelves.remove(id);
    }
}

describe says each event in the past tense for the history ("Ann put “Rice” on “Cupboard”").

Test it, and let soda_local::check attack it with what a hostile member could sign:

#[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 }),
        // …
    ]);
}

Name the model in src/main.rs, then run soda check:

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

3. Add a page#

src/routes/page.rs is /. A page reads cx.app (the state), cx.space and cx.me, and returns a View:

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

pub fn page(cx: &Cx) -> View {
    let (shelves, today) = (cx.app.shelves(), cx.today());
    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} {today} /> })} }
        }}
    }
}

Show editing controls only when cx.me.can_edit; the engine refuses changes otherwise anyway. A component is a function whose arguments are its props ({cx} is short for cx=cx). The SDK's components (Button, Input, Tabs, Dialog, Menu, Empty…) are in the reference.

4. Post a form#

// src/routes/actions.rs
#[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(())
}
<form action=AddShelf class="flex gap-2">
    <Input name="name" maxlength=MAX_NAME required />
    <Button submit>"Add the shelf"</Button>
</form>

cx.act runs decide, then signs, encrypts, sends and applies the events, and every open page re-renders. A refusal comes back as the Err, and ? shows it. Make ids in the action (new_id()) so decide stays pure.

  • cx.keep(&["use_by"]) keeps those fields filled after the form succeeds.
  • Arguments besides form come from the view: AddItem(shelf.id.clone()), on:click=UseUp(item.id.clone()).
  • cx.say("Removed the shelf.") shows a notice. cx.say_with_undo("Used up “Rice”.") shows it with Undo beside it, for a change made in one tap.

5. Drag between shelves#

<Lists> lets items be dragged between lists, or moved with Alt and the arrow keys. Nothing moves until the model moves it:

#[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= only accepts an action taking a Moved. Mark an item's buttons data-drag-hide.

6. Put the view in the address#

What a page shows lives in its address, never in the instance, so reload, Back and shared links all work.

PartHoldsExample
PathA place: a screen, a thing's page/soon
QueryA filter, search or sort?q=rice
QueryA dialog over the page?edit=…

A folder in parentheses is a group: its pages sit at the parent's address, and its layout wraps only them.

src/routes/
  layout.rs            around everything
  (stock)/
    layout.rs          the tabs and the edit dialog
    page.rs            /
    soon/page.rs       /soon
  shelves/[shelf]/     /shelves/:shelf

Tabs between pages are links. cx.link knows whether it's the current page (cx.link_within also matches pages under it). Tabs that filter one list use the query instead: cx.choice("show", &["todo", "done"]).

<Tabs label="Show" pills>
    <Tab href=cx.link(link::root())>"Every shelf"</Tab>
    <Tab href=cx.link(link::soon())>"Use soon"</Tab>
</Tabs>

A dialog is open while the address says so, and Back closes it:

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

An edit form carries the revision it showed. If someone changed the item meanwhile, the person is told and can apply theirs anyway:

let rev = soda_local::ui::rev(cx.rec, &format!("item:{}/details", item.id));
// a hidden "rev" field in the form; then in the action:
match form.rev {
    Some(rev) => cx.act_seen(command, rev).await?,
    None => cx.act(command).await?,
}

The space is in the address too: /~3fa9c2e1b0/soon. Your routes never see it, so two tabs can show two pantries. /~new/ starts one, and cx.space_link(&id) links to one.

7. Give a shelf its own page#

A folder in brackets is a param: shelves/[shelf]/page.rs is /shelves/:shelf, read as cx.params.shelf and linked with link::shelves::shelf(&id). Return NotFound for a missing one:

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

<EditInPlace current-name=… current-rev=…> renames it in place: a <details> whose form opens filled from those attributes, so typing survives others' changes. After removing a shelf, cx.go(link::root()) moves the page.

8. Use days#

A use-by date is a day, not a moment. soda_local::Day is written 2026-10-20 and is a form field type (Option<Day> when optional). <Date day=… /> shows it in the reader's language, and cx.today() is today in UTC. A page that reads cx.today() or cx.now() re-renders each minute; for something that ticks by the second, use <Countdown> or <Clock>.

9. Offer a download#

#[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. Add undo, and handle colliding edits#

The bar's undo and redo (⌘Z, ⇧⌘Z) cover this device's changes. step records what a change did; travel makes the events to go back or forward, only while what it touched is unchanged:

(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)),
},

target names what an edit is to. When two people edit the same target at once, the one that reaches everyone second doesn't count, and its person is asked whether to apply it anyway:

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. Style, test and deploy#

web/app.css holds the design tokens, and everything (Soda's screens too) uses them. See styling.md.

soda check    # model tests, soda_local::check, events.lock, Clippy, the token check
soda test     # check, then a build and tests/e2e.mjs in real browsers (needs a local store)
soda deploy   # a production build, published with Vercel (after `vercel link`)

Before deploying, swap the SDK paths in Cargo.toml for the repository (the comment there has the lines) and run cargo update -p soda-local, so Cargo.lock pins the commit. Without SODA_STORE_URL, builds use the SDK's hosted store.

soda dev brings its own store; soda test doesn't. Its build, without SODA_STORE_URL, uses the hosted store, whose browser check automated browsers can't pass. Point it at a local store: run supabase start in the SDK checkout (supabase status prints the key), then set SODA_STORE_URL and SODA_STORE_KEY.

In depth#

How it fits together#

page (HTML, Datastar)  ──POST /api/…──▶  instance (Rust, in the service worker)
        ▲                                   │ decide → sign → save → send
        └──── #app re-rendered ─────────────┤
                                            ▼
                          store (sealed rows)  ◀──▶  other members' instances

The instance serves the page already rendered and keeps one stream open. On every change it renders #app again and Datastar morphs it in. Nothing under /api/ leaves the device.

The model's rules#

impl App for Items {
    type Event = ItemEvent;
    type Command = ItemCommand;
    type Step = Step;
    const NOUN: &'static str = "list";

    fn decide(space: &Space<Self>, author: &str, now: u64, command: ItemCommand) -> Result<Vec<ItemEvent>, String>;
    fn evolve(space: &mut Space<Self>, author: &str, ts: u64, event: &ItemEvent);
    fn describe(before: &Space<Self>, who: &str, event: &ItemEvent) -> String;
    fn what(step: &Step) -> String;  // "Undo …"
    fn step / travel / gone          // undo, optional
}

Space<A> adds name, creator (the owner's account id), members, epoch, removed and former (the names of people who left); your state is space.app. A soda::Model (the board template's) runs here through soda_local::LocalFirst: see events.md.

decide is pure, and anything random or time-based comes in with the command. evolve runs on events from every device, so in evolve:

  • Re-check everything; ignore what decide would refuse. Validate ids (valid_id), bound text (fit, fit_to), check that referenced things exist, enforce limits. Don't repair bad events.
  • Never panic. No unwrap, no out-of-range indexing, no byte slicing of strings (&s[..10] panics on é).
  • Never trust a number. Floats can be NaN or infinite; counts can be huge. The space must survive a JSON round trip.
  • Check permissions against author. Owner-only actions check space.creator. Store who owns what and check it on every change; keep roles in state, set by owner-signed events. App::may_rename limits renaming the space.
  • Use ts, never the clock. It's the agreed time, the same everywhere, and the natural order.
  • Cap what one member can make everyone keep, in both decide and evolve.

soda_local::check replays your history (by "owner", "ann" and "bob"), then attacks evolve thousands of times with altered events and events from "eve", a stranger. It fails on panics, nondeterminism, strangers changing anything, spaces that don't survive saving, oversized text, broken undo, late changes that can't be redone, and false target claims. It only attacks event kinds your history uses, so use every kind. Add your own permission rules with run_with_rules:

soda_local::check::Check::new().max_text(MAX_TEXT).run_with_rules::<Potluck>(history, |before, after, who| {
    if after.app.place != before.app.place && before.creator.as_deref() != Some(who) {
        return Err("only the host sets the place".into());
    }
    Ok(())
});

Events are forever#

You may add event kinds, and fields that can be missing (#[serde(default)]). You may never remove a kind or field, or change a field's type. To rename one, keep its old name as an alias (#[serde(alias = "title")] name: String): old events still read, and the lock keeps both. Before the first deploy, delete events.lock freely. If evolve changes how it treats events already signed, raise App::RULES so every device re-folds.

  • Event names share a namespace with the SDK's: say what they're about (ItemAdded, not Added), and avoid BoardCreated, BoardRenamed, MemberJoined, KeyChanged, EpochOpened and HistoryClosed.
  • State fields sit beside the SDK's: none may be name, creator, members, epoch, removed or former.
  • Use BTreeMap, not HashMap, for anything you iterate or render.

Colliding edits#

Targets are paths: item:7f/text is under item:7f. An edit counts only if nobody changed its target, or anything above or below it, since the revision it was made against.

  • Return None for an edit evolve would ignore because it isn't the author's (a taken id, someone else's recipe), or a member could use it to make others' edits lose.
  • Return None for records, which always count: a note, a vote, a message.
  • Give separately edited parts their own targets, under 128 bytes. Finer targets, fewer conflicts.
  • Targets protect honest people from each other. evolve is what stops a hostile member.

Undo, and late changes#

A change that arrives late (a phone offline for hours) is made again with step and travel: its step against the space before it, travelled forward on the space now. If that fails, it's dropped and its author told. Without a step it's always dropped, so write one for every kind of change.

  • step(before, author, events) records enough to go back, and to go forward again from before: the whole thing added or removed, not just its id.
  • travel returns the events that cross the step, or Stale::changed_by(who), Stale::gone() or Stale::because("…"). Its events are signed by author, so evolve must accept them.
  • what finishes "Undo …". Your own undo buttons take data-soda-undo and data-soda-redo.
  • Owned things: to restore Bob's dish as Bob's, keep removed dishes in state (bounded) and add a DishRestored { id } that evolve takes only from the remover.
  • People who leave: App::on_removed(space, who) tidies their things; space.name_of(who) keeps their name.

Spaces only some members see#

For a part only some members should read, make a second space and offer it through the first: its keys travel, sealed to each chosen person's devices, in the first space's log.

  • Make it with Engine::create_linked(name, setup), then link it with an event in the first space. Check space ids with space::valid_space_id.
  • Share it with Engine::seal_offer(space, &people); keep only boxes that are KeyBox::valid().
  • App::offers lists what a space offers. accepts_offer(from) is the offered space confirming it's the right kind, made for from: until it says yes, nothing is joined or signed in it. tucked keeps it out of the list of spaces.
  • Take someone out with Engine::withdraw_offer(from, space, person) and App::unoffer. It changes the key first; never remove them from the list yourself. When someone leaves the first space, the owner's device withdraws their offer by itself.

The SDK's screens and elements#

The engine places its own screens (<soda-gate>, <soda-start>, <soda-bar>, <soda-notices>, <soda-share>, <soda-security>, <soda-recovery>, <soda-toasts>), worded with your app's name and App::NOUN, styled by your tokens. web/soda-elements.js has the browser-only elements behind the framework's components; don't define elements with their names.

Element or attributeDoes
<soda-when>Picks a moment in the reader's zone, posted as ms
<soda-feed>A list that stays at its newest item, unless scrolled back
<soda-mention>A message box that offers names after @
<soda-copy>A Copy button
<soda-inline>Text edited in place (soda-commit)
<soda-filter>Shows only what matches what's typed, or who changed it
<soda-device>Whether this browser keeps the site's data, and is installed
data-soda-key="/ mod+k"That key presses the control
textarea[data-soda-send]Enter sends the form, Shift+Enter is a new line
data-soda-print, data-soda-sharePrints the page; opens the share sheet
data-soda-titleSets the tab's title

From your own JavaScript: soda.ask(path, body) (answered {ok, error, …}), soda.post, soda.toast(message), and soda.putFile(file) to seal a file on this device.

  • Files: <soda-file name="photo"> seals a file for its form, which needs a matching <input name="photo" hidden>: a hidden text input, not type="hidden", or the next update wipes the id. The action takes it with soda_local::files::take_staged(id); evolve keeps only valid() FileRefs; App::file(id) serves it at /api/file/<id>. Photos lose their location data before sealing.
  • Records: soda_local::record (with App::KEEP_HISTORY): Chain::new(format, subject) builds a signed chain, record::seal signs its head, and record::check verifies one (so does web/soda-record.js, in a browser).
  • PDFs: soda_local::pdf::Doc lays text out on US Letter pages, with no fonts to ship.
  • Split keys: soda_local::shamir::split divides a secret among 2 to 7 people, any k (at least 2) of whom combine it again.
  • Requests to the instance need a Datastar-Request or X-Soda header, which another site's form can't send. The engine's own routes (/api/space/…, /api/undo, /api/scrub, /api/file/…) are what the screens post to.

Rules for views#

  • Never name a form field id: page updates match elements by id and would replace the form.
  • Rendering someone's HTML (Markdown) needs a sanitizer that keeps only plain tags with no attributes, inside a data-ignore element.
  • Put people's names in <bdi>. Escape text yourself for anything that isn't HTML (CSV, a calendar file).
  • Render from the folded space, never by walking the log, and only what's visible.
  • Without the framework (the soda_local::Site trait and maud views): post data with ui::post(path, json), never a data-on:* built with format!; say which thing a form is for with ui::post_with, never a hidden field bound to a signal; never use onclick.

Testing and deploying#

  • Against a local store, open localhost, not 127.0.0.1: passkeys need a hostname. Each browser profile is a separate device.
  • Give each app its own domain: apps on one origin share a service worker.
  • Set the Content-Security-Policy on pages, never on sw.js: the worker reaches the store. Its frame-src must allow the store's browser check (soda-check).
  • Set SODA_SDK=<checkout> to build against a local SDK checkout instead of the pinned commit.
  • Commit events.lock and Cargo.lock. After the first deploy, events only grow.

Checklists#

Security

  • evolve re-checks ids, text length, existence, limits, numbers and permissions, and never panics. soda_local::check passes with your real max_text.
  • New ids come from new_id() in actions; decide and evolve never read the clock.
  • Ownership, owner-only and role checks are in evolve (and decide), against author, and run_with_rules states them.
  • App::may_rename limits renaming, if not everyone may.
  • evolve keeps only valid() FileRefs and KeyBoxes, and space ids that pass space::valid_space_id.
  • Removing someone from an offered space goes through Engine::withdraw_offer.
  • Nothing about a person leaves their device except through the engine: no analytics, no third-party scripts, fonts or images.
  • Without the framework: maud views, ui::post and ui::post_with, no onclick, no inline <script>, no PreEscaped of anyone's text.

Correctness

  • Events derive soda_local::Shape; events.lock is committed; only additions after the first deploy.
  • State uses BTreeMap, with no field named name, creator, members, epoch, removed or former.
  • Order comes from ts (then an id), never arrival.
  • Every edit to something shared has a target (records don't), fine enough that editing different parts doesn't conflict.
  • Every kind of change has step and travel: undo refuses when someone changed the same thing since, and forward works from before.
  • A removed person's things are handled (App::on_removed) and their name still shows.

Experience

  • The start screen's intro says what the app is in a sentence or two.
  • Every list has an empty state; every refusal says what to do instead.
  • Controls show only when the person can edit; visitors see the space read-only.
  • What a page shows (tab, search, open item) is in its address.
  • Works at 390 px wide and by keyboard alone; buttons are at least 44 px; dark mode is set.
  • Destructive actions say what they do; undo covers the rest.
  • soda test passes, including your own flows.

Performance

  • Views render from the folded space, proportional to what's visible.
  • evolve does constant or logarithmic work per event.
  • The wasm stays small: opt-level = "z", no heavy dependencies.

Next#

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

Esc