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#
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.
| Where | What |
|---|---|
src/model.rs | The rules: state, events, and how commands become events |
src/routes/ | The pages, and actions.rs for what their forms post |
src/components.rs | Shared #[component]s |
src/main.rs | soda_local::app!, naming the model |
web/app.css | The design tokens |
tests/e2e.mjs | End-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.
pub type Pantry = ; // your state beside the SDK's: name, members, creatorEvents are signed and kept forever, so they can only grow. cargo test keeps events.lock
and fails on a change that would break one.
decide turns a command into events, or refuses it in words the person can act on:
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:
ShelfRemoved => 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:
Name the model in src/main.rs, then run soda check:
app!;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:
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
pub async <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
formcome 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:
pub async <Lists moves=MoveItem disabled=!cx.me.can_edit>
</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.
| Part | Holds | Example |
|---|---|---|
| Path | A place: a screen, a thing's page | /soon |
| Query | A filter, search or sort | ?q=rice |
| Query | A 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/:shelfTabs 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>"Every shelf"</Tab>
<Tab href=cx.link>"Use soon"</Tab>
</Tabs>A dialog is open while the address says so, and Back closes it:
let editing = cx.query.and_then;
view! 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 = rev;
// a hidden "rev" field in the form; then in the action:
match form.rev 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:
<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#
// 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:
=> match stock.items.get ,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:
11. Style, test and deploy#
web/app.css holds the design tokens, and everything (Soda's screens too) uses them. See
styling.md.
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' instancesThe 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#
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
decidewould 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 checkspace.creator. Store who owns what and check it on every change; keep roles in state, set by owner-signed events.App::may_renamelimits 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
decideandevolve.
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:
new.max_text.;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, notAdded), and avoidBoardCreated,BoardRenamed,MemberJoined,KeyChanged,EpochOpenedandHistoryClosed. - State fields sit beside the SDK's: none may be
name,creator,members,epoch,removedorformer. - Use
BTreeMap, notHashMap, 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
Nonefor an editevolvewould 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
Nonefor 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.
evolveis 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 frombefore: the whole thing added or removed, not just its id.travelreturns the events that cross the step, orStale::changed_by(who),Stale::gone()orStale::because("…"). Its events are signed byauthor, soevolvemust accept them.whatfinishes "Undo …". Your own undo buttons takedata-soda-undoanddata-soda-redo.- Owned things: to restore Bob's dish as Bob's, keep removed dishes in state (bounded) and add a
DishRestored { id }thatevolvetakes 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 withspace::valid_space_id. - Share it with
Engine::seal_offer(space, &people); keep only boxes that areKeyBox::valid(). App::offerslists what a space offers.accepts_offer(from)is the offered space confirming it's the right kind, made forfrom: until it says yes, nothing is joined or signed in it.tuckedkeeps it out of the list of spaces.- Take someone out with
Engine::withdraw_offer(from, space, person)andApp::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 attribute | Does |
|---|---|
<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-share | Prints the page; opens the share sheet |
data-soda-title | Sets 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, nottype="hidden", or the next update wipes the id. The action takes it withsoda_local::files::take_staged(id);evolvekeeps onlyvalid()FileRefs;App::file(id)serves it at/api/file/<id>. Photos lose their location data before sealing. - Records:
soda_local::record(withApp::KEEP_HISTORY):Chain::new(format, subject)builds a signed chain,record::sealsigns its head, andrecord::checkverifies one (so doesweb/soda-record.js, in a browser). - PDFs:
soda_local::pdf::Doclays text out on US Letter pages, with no fonts to ship. - Split keys:
soda_local::shamir::splitdivides a secret among 2 to 7 people, anyk(at least 2) of whomcombineit again. - Requests to the instance need a
Datastar-RequestorX-Sodaheader, 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-ignoreelement. - 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::Sitetrait and maud views): post data withui::post(path, json), never adata-on:*built withformat!; say which thing a form is for withui::post_with, never a hidden field bound to a signal; never useonclick.
Testing and deploying#
- Against a local store, open
localhost, not127.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. Itsframe-srcmust 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.lockandCargo.lock. After the first deploy, events only grow.
Checklists#
Security
-
evolvere-checks ids, text length, existence, limits, numbers and permissions, and never panics.soda_local::checkpasses with your realmax_text. -
New ids come from
new_id()in actions;decideandevolvenever read the clock. -
Ownership, owner-only and role checks are in
evolve(anddecide), againstauthor, andrun_with_rulesstates them. -
App::may_renamelimits renaming, if not everyone may. -
evolvekeeps onlyvalid()FileRefs andKeyBoxes, and space ids that passspace::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::postandui::post_with, noonclick, no inline<script>, noPreEscapedof anyone's text.
Correctness
-
Events derive
soda_local::Shape;events.lockis committed; only additions after the first deploy. -
State uses
BTreeMap, with no field namedname,creator,members,epoch,removedorformer. -
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
stepandtravel: undo refuses when someone changed the same thing since, and forward works frombefore. -
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 testpasses, including your own flows.
Performance
- Views render from the folded space, proportional to what's visible.
-
evolvedoes constant or logarithmic work per event. -
The wasm stays small:
opt-level = "z", no heavy dependencies.
Next#
- Reference: components, routes, actions, forms,
Cx, the CLI. - Events: the same kind of model on a server.
- The local-first engine: how it stays correct.
This page is docs/local-first.md in the SDK, built into this site as it is.