How it's built
Soda: Rust and Datastar apps written like Next.js and SvelteKit ones
Status (2026-10-17): built, milestones 1 to 6 (§13). Soda is in this
repository's kit/ (the crates soda, soda-macros and soda-build); its two backends
(§16) are soda-server (server/) and
soda-local (the repository's root, the engine it started as); the templates new apps start from
are templates/server and templates/local. What's still a plan is marked so.
1. The aim#
Someone who has built a Next.js or SvelteKit app should be able to read a Soda app and know where everything is, and write their first page in minutes:
- a folder of routes, where a folder is a URL and a file says what it is (
page.rs,layout.rs); - pages and layouts written as markup with components, styled with Tailwind;
- forms that post to typed actions written beside the page that uses them;
- components that take a
classand look the way it says, inside and out; - one command to start (
soda dev), one to build (soda build), and edits that reach the open page in about a second, what it holds kept.
Most of what makes Next and SvelteKit complicated is keeping the browser and the data in step: loaders, caching and revalidation, loading states, the line between server and client components. A Soda app has none of that. A page is a plain function of what it reads and its address, an action changes things, and every open page draws itself again. That's the advantage to press: the routing and components of a modern framework, with none of the data plumbing.
Where an app runs is its backend (§16), and the pages are the same on either:
- On a server (
soda-server), the app is a program that draws every page from what it keeps (in memory, or a database it opens). The first paint is its own HTML, and every open page holds one Datastar stream it's drawn again on after any action, anyone's. Deploying is running a program. - Local-first (
soda-local), each person's browser runs the app, and it does what those frameworks can't, without the app doing anything for it: every app is realtime and multiplayer, works offline, keeps a signed history with undo, and is end-to-end encrypted, with no backend to run. Deploying is publishing static files.
What carries over, and what doesn't#
| Next.js / SvelteKit | Soda |
|---|---|
app/blog/[slug]/page.tsx, +page.svelte | src/routes/blog/[slug]/page.rs |
layout.tsx, +layout.svelte | layout.rs, nested the same way |
not-found.tsx, error.tsx, +error.svelte | not_found.rs, error.rs |
(group) folders | (group) folders |
[...rest], [[optional]] | [...rest], [[optional]] |
| Server actions, form actions | #[action] functions beside the page |
<Link href>, goto() | <a href=link::…> (typed); cx.go(…) in an action, soda.go(…) in the page |
generateMetadata, <svelte:head> | pub fn head(cx) -> Head |
Loaders, fetch, revalidatePath, suspense | Nothing: a page reads what it needs as it's drawn (cx.state, or the open space), and every change reaches every open page |
useState, $state (client-only state) | Datastar signals (bind:, show=) |
| Client components, islands | Rocket components, on the app's own utilities |
cn() with tailwind-merge | cn, read off the app's own compiled stylesheet: no table to keep (§7) |
| Fast Refresh, HMR | soda dev: Rust patched into the running app in about a second, CSS in a fifth, what it holds kept (§9) |
next build, Vercel | soda build: a program and its site (on a server), or static files for any host (local-first) |
| API routes | #[download]s for exports and printables; a server's own routes are to come |
What's genuinely different on the local-first backend, and stays different: an app's rules are
its model (decide and evolve, see building-an-app.md), and an action
changes the state only by deciding events, which every member's instance replays. There's no
trusted server, so nothing secret lives in the app (no API keys, no payment processing). An app
that needs those runs on a server instead, with the same pages.
2. What an app looks like#
pantry/
Cargo.toml soda and soda, and soda-build for build.rs
build.rs fn main() { soda_build::routes(); } (compiles again when a route is added)
events.lock how events are written (as for any Soda app)
src/
main.rs mod components; mod model; soda_local::app!(model::Pantry);
model.rs state, events, commands, decide/evolve
components.rs components the routes share
routes/
layout.rs inside every page (and its words on the start screen)
page.rs /
not_found.rs
shelves/
page.rs /shelves
[shelf]/
layout.rs the shelf's header and tabs, around every page under it
page.rs /shelves/:shelf
actions.rs what that page's forms post to
items/
[item]/page.rs /shelves/:shelf/items/:item
(account)/
settings/page.rs /settings ("(account)" groups without adding to the URL)
web/
app.html the shell
app.css the design tokens
components/
expiry.js the app's own browser components
kit.js loads them
tests/
e2e.mjsOne crate, at the root. An app made before Soda has its crate in instance/ and its Site
written by hand, and keeps building as it is.
The app is a program: src/main.rs names the model, and what the page says while it catches up
(soda_local::app! writes the rest, its main included):
app!;3. Routes#
Files#
| File | What it is |
|---|---|
page.rs | The page at that folder's URL: pub fn page(cx) -> View (or Result<View>), and an optional head. A folder without one is only a path to the ones inside. |
layout.rs | pub fn layout(cx, children: View) -> View, around every page at and under its folder. Layouts nest: the root's, then each folder's down. |
actions.rs | Actions for the pages in its folder. (An #[action] in any of a folder's files is one of its actions.) |
not_found.rs | Shown for a URL nothing matches, or when a page returns NotFound. The nearest one up the tree, inside the layouts above it. |
error.rs | Shown when a page returns another error. The nearest one up the tree. |
| Folder | Matches |
|---|---|
shelves | /shelves |
[shelf] | one segment, given to the page as shelf |
[...path] | one or more segments, as path: Vec<String> |
[[lang]] | an optional segment |
(group) | nothing: groups routes (sharing a layout) without adding to the URL |
A page#
// src/routes/shelves/[shelf]/page.rs
use Params;
use AddItem;
use crateItemRow;
use crate*;
Cxis everything a page reads:cx.app(the open space's state, the app's model),cx.space(its name and members),cx.me(who's looking:id,name,can_edit,owner),cx.params,cx.page(the address, with its query) and the engine's own state for the few things that need it (cx.engine).cx.query("tab")reads the query,cx.query_as::<Query>()reads it into a type,cx.here_with(&[("tab", Some("history"))])links to this page with its query changed, andcx.name_of(id)is a member's name.- Each folder has its
Params, generated (crate::routes::shelves::shelf::Params, which a route file reads assuper::Params): a field for each param from the root down, aStringfor[shelf], anOption<String>for[[lang]], aVec<String>for[...path]. A Soda id is text, so there's nothing to parse; a page that wants another type parses it and returnsNotFoundwhen it doesn't. A function that doesn't need them takes&Cx. - What a page is looking at belongs in its address, as for any Soda app (building-an-app.md).
- A page returns a
Viewor aResult<View>.NotFoundshows the nearestnot_found.rs; any other error shows the nearesterror.rs(or a plain one). headis optional:Head::title(…)puts "Title · App name" in the browser's tab as the page moves.
A layout#
// src/routes/shelves/[shelf]/layout.rs
use Params;
use crate*;
A layout survives moving between the pages under it, for nothing: the page's update is a morph, so an unchanged layout is left as it is in the page, with its open menus, its scroll and its focus.
Soda's own screens are the root of every app: the account screens (opening, locked, adding this
browser, restoring), the start screen, and, once a space is open, its bar, its notices, its sheets
and toasts. The root layout.rs sits inside them, and shapes them with two more functions, both
optional: intro(), the app's words on the start screen, and bar(cx), its own buttons in the
space's bar.
Typed links#
The router is generated, and so are its links: a function per page, taking its params.
root // "/"
shelves // "/shelves"
shelf // "/shelves/rice-and-grains"
shelf.with // "/shelves/rice-and-grains?tab=history"A link to a page that doesn't exist, or with the wrong params, doesn't compile. Renaming a folder
renames its function, and the compiler lists every link to fix. Params are anything that displays
(an Option for [[x]], an iterator for [...x]), encoded for the URL. A group isn't in the URL,
so it isn't in the links either.
How it's built#
Rust can't read a folder of routes at run time, so the compiler does: soda_local::app! reads
src/routes/ every time the crate compiles (a hot patch's compile too, which is why it's a macro
and not a build script's output), and writes:
- a module for each folder, named for it (
[shelf]isshelf,(account)isaccount), with its route's pattern (ROUTE), itsParams, and its files by#[path]: sosrc/routes/shelves/[shelf]/actions.rsiscrate::routes::shelves::shelf::actions; - the matcher, most specific first (fixed segments, then
[param], then[[optional]], then[...rest]), drawing each page inside its layouts, with each address no page answers going to the nearestnot_found.rs; - the dispatcher for the actions;
- the
link::functions; - the app's
CxandAct(the model filled in) and itsprelude; - the
soda_local::Sitethe service worker runs, so the engine doesn't change.
What it can't make sense of is a compile error naming the folders: two pages for one address
(src/routes/[id] and src/routes/[slug]), two pages with one link name, two folders that would be
one module, a file that isn't a route's, a folder name a URL can't have, a page.rs without its
page. Each is one error: what's at fault is left out and the rest of the router is still written,
so the app compiles around it. A route file that doesn't parse is included as it is, so the
compiler points at the line. A new folder changes no file the compiler reads, so build.rs (one
line, soda_build::routes()) has Cargo compile again when the folder changes. rust-analyzer
expands the macro, so go-to-definition and completion work on routes and links.
Moving between pages#
A page moves without loading again:
soda.jstakes any link to the same site (not/api/…, a file, a download, a new tab or a link within the page), pushes it onto the history and tells the instance (/api/page); the instance renders the page there, and the stream morphs it in. Back and Forward do the same.- Every
#appthe instance sends says which path it was drawn for (data-path). On a move to another path,soda.jswaits for the view drawn for it, then scrolls to the top, or back to where that step of the history was (for Back, Forward and a reload: the browser's own restoring is off, since it would scroll the old view), and puts the keyboard and a screen reader on its<h1>, as on a page loaded afresh. A move within a page (its query: a tab, a filter) keeps both. - An action can move the page that sent it:
cx.go(link::shelves::shelf(&id)). - The service worker renders a page at any path, so a reload or a shared link to
/shelves/riceopens there. The static host sends every path that isn't a file to the shell (vercel.json'srewrites), so a first visit to a deep link works too. - A move to another path is drawn as a view transition:
soda.jsasks for one (/api/page'stransition) unless motion is turned down (prefers-reduced-motion) or the browser animated the move itself (a swipe back), and the backend sends that drawing with Datastar'suseViewTransition. A move within the page (a tab, a filter) is at once; others' changes arriving animate too, on the local engine. How it looks is the app's CSS (styling.md).
4. Views and components#
view!#
Markup is written the way JSX and Svelte write it, in a view! macro:
view! - Text is in quotes, and everything is escaped, always: there's no way to write unescaped markup
from data.
{…}takes anything that renders: text, numbers, aView, anOption, an iterator of views. class="…"is literal, so Tailwind finds every class.class:done=condadds a class when it holds (Svelte's syntax), for any class Tailwind has (class:bg-accent/10=selected);class=expradds classes from a value (text, aClass, or anOptionof them:class={done.then_some("…")}). However many there are, they're one attribute, joined bycn(§7).on:click=…takes an action (§5) or a Datastar expression in quotes,bind:draftbinds a signal,show="$open"shows and hides. They write Datastar's attributes (data-on:click,data-bind="draft",data-show); a bound field inside a component (<soda-mention>) still binds the page's signal. Nothing else can become code:on:takes nothing else, and a Datastar attribute written out (data-on:click=…,data-show=…) takes only a string in quotes. An expression built in code is aHandler::raw(…), given toon:.- An attribute takes text, a number, a
bool(there or not;aria-*and the like say "true" or "false") or anOption. - An element whose whole content is text is isolated (
dir="auto"), so a name someone typed right to left can't turn the words around it. view!makes maud'sMarkup, sohtml!still works beside it.
Its errors point at the attribute in the app's own source, and say what to write instead ("text in
a view goes in quotes: "Save"", "a component's type is its kind prop").
Components#
A component is a function:
// <ItemRow {cx} {item} compact class="mt-2" />- Props are named arguments, every one required unless it's a
bool(false unless given), anOption,class: Class,attrs: Attrsorchildren: View(empty), or marked#[prop(default)](or#[prop(default = …)]). A missing prop is a compile error naming it. Text props take anything that converts (#[prop(into)]for others), and anOptiontakes a value,Someof one orNone, so a view passes whatever it has:met=deadline.met. {item}isitem=item, and a barecompactiscompact=true.- An attribute named with
-or:goes to itsattrs(written with{attrs}on its element), andattr:name=…sends any attribute there (<Input attr:enterkeyhint="done" />). - They render in the instance, like pages: no JavaScript, nothing to hydrate.
The SDK brings the components most apps need, each drawing Soda's own classes on the app's tokens (see styling.md), and each doing in the browser only what the SDK's own elements already do:
- Controls:
Button(primary,quiet,danger,small,submit,cancel),Input,Textarea,Select,Field(a label, the control, its hint and its error),Card,Chip. - Moving between views:
TabsandTab,Dialog,MenuandMenuItem. - Editing:
EditInPlace(a<details>that opens a form where the thing is, a snapshot of it as it opened),Lists,ListandListItem(items dragged between lists, or moved by keyboard, posting aMovedto an action taking one:<Lists moves=MoveCard>). - What's there:
Time,Clock,Countdown,History(the space's history line),Empty,Avatar.
What the page is showing is in its address, never in the instance. 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 (views
of one thing sharing a frame are pages in a group, with a layout around them). The query refines
the place you're on: a filter, a search, a sort (cx.choice reads one, keeping only what's an
option), and a dialog over it, open while the address says so and closing to the page without it
(Back closes it too). Soda's own sheets are dialogs like any other: ?sheet=share is Share open
over the page you're on, ?sheet=security Security (on:click=Sheet::Share opens one from the
app's views, cx.sheet() says which is open). Tabs are links either way (cx.link says whether
one is to this page). A reload, Back and a link sent to someone all keep it, and two tabs of one
app each show their own.
On the local engine the address names the space too: /~3fa9c2e1b0/shelves/rice is
/shelves/rice in the space whose id starts so. The app never sees it. Its routes read the path
within, and its links, moves and downloads are written under the page's space for it (its base,
as SvelteKit calls it: soda::route::based, which a server app doesn't use). An address naming
none opens the space opened last, and is put under it; /~new/ is the start screen. So two tabs
show two spaces, each acting on its own, a reload keeps each, and a link one member sends another
opens the same space on theirs. Every request a page makes is in its page's space, even as
requests from two tabs take turns waiting on the store.
5. Actions#
// src/routes/shelves/[shelf]/actions.rs
use Params;
use crateCommand;
use crate*;
pub async
pub async - A view names an action by its function's name in CamelCase, as an enum's variants are named:
<form action=AddItem>posts the form toadd_item, andon:click=Remove(item.id.clone())posts the arguments given toremove. (A snake-case value would be mistaken for any local variable of its name, solet remove = …couldn't be written beside it. A form's own type is named for what it holds,NewItem, or…Form.) The address (/api/_/shelves/rice/add_item) is the action's route filled in from the page, so an action gets the sameParamsas its page, and nobody writes a URL. #[derive(Form)]reads the posted form into typed fields (text,Option<String>,bool, numbers and theirOptions,Vec<String>, or anyFromStrtype with#[form(parse)]), with the checks written on them: text is trimmed (unlesstrim = false),minandmaxare its length or a number's value,messagesays it otherwise. A field that fails comes back to the form and the action doesn't run:<Field name="name">shows the message under it, the field is marked invalid, and typing clears it.- An action changes the space only by
cx.act(command), which goes through the model'sdecide, signs, seals and sends. Its refusal (the engine's, or the model's own words) is shown to its person. So actions are thin: the rules stay in the model, wheresoda_local::checktests them against what a hostile member could sign. (new_id(), in the prelude, is an id for what it makes.) cx.act_seen(command, rev)is the edit made against what the form showed: a change someone made meanwhile isn't overwritten unseen.- A form whose action went through is emptied for the next (and the
<details>it was opened in closes), but for the fields the action keeps (cx.keep(&["kind"]), what's likely the same next time), or all of it (cx.keep_form()). Otherwise what happens next needs nothing: every open page (this person's, and everyone else's once it syncs) re-renders from the new state. An action can also move the page (cx.go), say something where every page shows it, with undo beside it (cx.say), or empty signals (cx.clear).
Downloads#
What a page offers to save (a calendar entry, an export, a printable) is a #[download] in the
route's downloads.rs, made by the instance from what's on this device as it's asked for:
// src/routes/downloads.rs
// in a view
<a href=Calendar download>"Add to calendar"</a>Its address is the route's, under /api/_ like an action's, with any arguments the view gives it
in its query (href=Receipt(order.id.clone())). soda.js fetches a download link through the
service worker, since the browser itself would go around it to the static host.
6. Client components#
What has to run in the page (a countdown ticking, dragging, a chart) is a Rocket component in
web/components/, imported by web/kit.js, written with Soda's component:
// web/components/expiry.js
- It's loaded before the page is first painted (
kit.jsis: see styling.md), and inview!it's used like any element:<pantry-expiry at=item.expires />. - Inside, it's written with the same Tailwind utilities as the views: its shadow root adopts one stylesheet every component shares, the app's own (§7). The tokens reach in by inheriting.
- Each part it names takes classes from outside (
badge-class="bg-warn text-warn-ink"), joined after its own bycn, so they win where the two set the same thing.
7. Styling#
Everything in styling.md stays: Tailwind v4, every color, font, radius and shadow a token, the check that refuses anything else. Soda adds three things.
Classes given to a component win#
A component's own classes come first, and a class given to it has to win wherever the two set the
same thing, whatever order Tailwind wrote them in. cn joins class lists, leaving a class out when
a later one sets everything it sets under the same variant: px-6 after px-4, p-4 after px-6
(a shorthand covers its longhands), hover:bg-accent after hover:bg-subtle, but not px-6
after p-4 (it sets less, and Tailwind writes the shorthand first) or bg-accent and
hover:bg-subtle.
tailwind-merge does this with a table of which utilities conflict, kept by hand in step with
Tailwind. cn has no table: the build reads what each utility sets off the app's own compiled
stylesheet (scripts/kit-styles.mjs writes it to app.classes.json), so it's exact for whatever
the stylesheet holds, the app's own tokens and utilities included, and can't fall out of step. The
instance's soda::cn and the page's cn read the same map, and the SDK's tests run both on
the same cases against Tailwind's own output.
One stylesheet inside every component#
The build writes app.styles.js beside app.css: the same utilities, components and reset, as
one CSSStyleSheet every component's shadow root adopts, but not the theme (:root, :host { --… }),
@property or @font-face. Those belong to the page: the tokens reach into shadow roots by
inheriting, and if a component's sheet declared them again on its host, a token an app sets on
part of the page (Potluck's color per kind of dish) would snap back inside it.
Measured 2026-10-16, with 500 components on a page, in Chromium slowed four times (about a phone): drawing them took 159 to 174 ms against 141 with no stylesheet, a changed token restyled them in 14 to 15 ms against 8, and the page held 2 to 4 MB more. A stylesheet per component instead took 458 to 1,829 ms and 35 to 158 MB more. The sheet's size barely mattered for the shared one (the template's 27 KB and Care's 94 KB measured alike): rules are found by class. WebKit, unthrottled, was in line (55 ms against 49).
class: names reach Tailwind#
Tailwind finds classes by reading source, and it would read class:bg-accent as bg-accent with
a class: variant. The build lists the names class: directives use for it
(scripts/view-classes.mjs), and the token check checks them like any other class.
Still to come: what apps reach into Soda's screens for (their titles, the start screen's grid, the
notices' width) as parts with class props, so the shells' <style id="soda-theme"> blocks can go.
8. The CLI#
cargo install --path cli from the SDK, then, in an app:
| Command | What it does |
|---|---|
soda new notes | A working app from a template, called what its folder is (scripts/new-app.sh): on a server, or with --local, local-first (soda new --local pantry --noun pantry) |
soda dev | Builds, serves and watches it, patching changes into the running app (§9) |
soda check | The app's tests (with soda_local::check and the events.lock, local-first), clippy, the token check |
soda test | check, then the build and the end-to-end tests in real browsers |
soda build | The site in dist/, and a server's program beside it, reproducibly (the app's scripts/build.sh) |
soda deploy | Local-first: a production build, published with Vercel (vercel build and vercel deploy --prebuilt). On a server: a container image of the program and its site (§16), 11 s to rebuild after a one-line change |
It runs the scripts of the SDK the app depends on, so an app and its tools stay in step, and
tells the backends apart by what the app's Cargo.toml depends on.
On the local-first backend, soda dev keeps a store of its own (cli/src/dev/store.rs): the store's protocol as the engine
speaks it (an anonymous session, the four calls, the Realtime socket that pushes new rows), at
/_store on its own server, over SQLite in target/soda/dev/store.sqlite, with what the store
promises the engine kept (swappable-stores.md) and none of the hosted store's
defenses (the browser check, meters, caps). So nothing but Rust and the CLI is needed to work on an
app: no Docker, no Supabase. SODA_STORE_URL points it at another store instead.
9. The edit-and-see loop#
This is where the gap with Next.js was widest. soda dev closes it by hot-patching the running
instance with Dioxus's Subsecond (hot-patching.md, which has how it works and
each case measured):
| You change | What you see | In the template |
|---|---|---|
| A page, a layout, a component, an action | the open page changes, without reloading | about a second (the first patch after a whole build, 3 to 4 s) |
| A style, a token, a class in a view | the stylesheet swapped in place, components and all | 0.16 s |
| A new route, a type's shape, the shell, Cargo.toml | built whole, and the page reloads where it was | 5 to 7.5 s |
| A browser component's script | the page reloads where it was | at once |
| A mistake | what's wrong, and where, over the page, until it's fixed | 0.2 s (with where, for a compile error, 1.2 s more) |
| A panic | the panic and where it happened, over the page; fixing it starts the instance again | about a second |
Kept means kept: the space, its history and what the page was showing survive every patch and every reload, because they live in the browser, not in the process being rebuilt.
On a server it's the same loop, with dx running the program and patching it as it runs
(tests/dev-server.test.mjs, on the guestbook): a page edit is in the open pages, drawn again on
their streams, in 0.6 to 0.8 s, and the server's state, every session and every open page are still
there; a new route in 1.5 s; a panic is drawn in the page it happened on, with where, and the page
is drawn again once it's fixed; a change to the shell reloads the pages (the server reads it from
disk under soda dev); a type's shape builds the server whole and starts it again, in 3.3 s, and
what it kept in memory starts again with it. soda dev passes the pages' requests to the program,
waiting while it builds or starts. What the app prints is in soda dev's terminal.
For comparison, scripts/build.sh (the release build) takes ten seconds for a changed page: a
native test, a release WebAssembly build, wasm-bindgen. Without dx (soda dev --no-hot), the dev
profile builds and the page reloads, 0.8 s for the build after a one-line change.
10. What Soda keeps from the SDK#
Unchanged underneath: the model (App: decide, evolve, describe, steps and undo), accounts
and devices, spaces and their keys, signing, sealing and sync, the store, the engine's own routes,
soda_local::check, events.lock, files, presence, records and PDFs, the reload that paints whole.
Soda is the layer an app is written in, on top of all of it. An app depends on both crates:
soda-local for its model, soda for its pages.
11. Costs and risks#
- Build time. A Rust change is seconds, not milliseconds (§9). If a page edit is over five seconds in the dev loop, hot-patching moves up.
- Macro errors.
view!,#[component],#[action],#[derive(Form)]and the route build must report errors at the app's own source, in plain words. The route build's are tested (kit/build/tests/routes.rs); the macros' are tested as compile failures (kit/tests/ui/). group-*andpeer-*don't reach into shadow roots. A component's insides can't react to agroupclass on the page. Components that need it take a prop instead.- Public pages. Before the worker is installed, a first visit shows the shell, which search
engines index poorly. A page that needs no space is
#[prerender]: it takes a&Public(aCxthat reads only its address, so it can't read a space by mistake), the build draws it into the shell as plain HTML (dist/<address>/index.html: the program, run off the browser with--prerender), and the worker draws it the same for anyone, before any account screen or the start screen. The local template's/privacyis one.
12. What changed in the SDK#
| Before | Now |
|---|---|
An app's crate in instance/, its routes in routes.rs matched by hand | One crate at the root, src/routes/ (the old shape still builds) |
Site::view written by the app, calling ui::page | Generated from the routes; the root layout inside Soda's screens |
Site::route matching /api/… paths | Generated from #[action]s; the engine's routes as before |
Page::get("tab") | Typed Params, cx.query; Page underneath |
?-only links moving the page; the worker rendering / | Any link to the site; the worker rendering any path; a static host rewrite; #app[data-path], scroll kept per step |
maud html!, the .hook."utilities" convention | view! (maud still works); a hook is a single word in class |
ui:: functions returning markup, soda-btn classes | #[component]s joined by cn (the classes stay underneath) |
Rocket components styled by ::part() and soda.theme() | component() on the shared sheet, parts' class props (::part() stays for Soda's own) |
web/index.html | web/app.html (index.html still works) |
Still to change: scripts/build.sh, styles.sh and new-app.sh become soda build and
soda new; a Supabase store in Docker for local work becomes soda dev's own (milestone 2).
13. Milestones#
Each ends with something to try, and doesn't start until the one before is good.
- The shape, in the template. Done 2026-10-16. Routes from folders (
page,layout,[param],not_found), typed links, the worker andsoda.jsmoving between paths (focus and scroll),#[action]with#[derive(Form)]and errors under their fields,view!and#[component]withcn, one Rocket component on the shared sheet. The template's end-to-end tests pass, with new ones for paths, Back and Forward, deep links, reloads, forms and the component; Kanban and Potluck pass theirs on the same SDK. Measured: the shared sheet's cost (§7) and the edit-to-see time as it stands (§9). - The dev loop. Built 2026-10-17:
soda dev, with Rust hot-patched into the running instance (hot-patching.md), the stylesheet swapped in place, reloads that keep the page where a patch can't, and mistakes and panics shown over the page;soda new,check,test,build,deploy.soda devkeeps its own store (§8), so working on an app needs only Rust and the CLI. - Potluck on it. Done 2026-10-17: moved whole (a program, its event a page of components,
its forms actions, its calendar file a download), its 25 end-to-end checks passing as they were,
and its screenshots against a build of the version before it the same pixel for pixel but for
the account's own color. Its wasm is 1.8 MB (2.0 before). What was awkward went back into
Soda: actions named in CamelCase,
#[download],cx.keep(…),class=taking anOption,items=&items {total}read as two things, and Datastar attributes refusing values from data. - The components and the docs. Done 2026-10-17: the components (§4), each drawing Soda's own
classes (one look, in web/soda.css, that markup no component draws takes too), with what a page
shows kept in its address (
cx.choice, dialogs that close to an address);soda_local::Dayfor calendar days; local-first.md (then calledtutorial.md), building Pantry fromsoda newto deployed, and reference.md. Pantry isexamples/pantry: its 11 browser checks pass (dragging, the dialog and Back, tabs, menus, editing in place, undo, a download, two people), and CI builds it. Found building it, and fixed in the SDK: a form emptied, by word of its last submit, after its person had started the next. - Kanban, Soda IR and Care on it, one at a time, each compared against production. Soda IR
is on it, and deployed (2026-10-17): every screen a page at its own address (an incident's room
at
/incidents/<id>, its checklist at/incidents/<id>/checklist), its dialogs?dialog=…over the page, an old address (/?view=binder&tab=vendors) sent on to its screen. Its 27 browser checks pass, and its screens match the build before it pixel for pixel, but for the times. Found porting it, and now the SDK's: aHandlerprop takes an action (an optional one,SomeorNoneof one), anOptionprop a borrowed value,action.url()andaction.with_signals(…),Sheet::ShareandSheet::Security, aCountdown's orClock's own text before it's drawn, form fields named anything,(…)or{…}around an attribute's value, andbind:binding the page's signal inside a component (<soda-mention>) too. Care is on it, and deployed (2026-10-17): its rail and header built from Soda's parts (cx.bar(…),cx.notices(), the start screen'sslot="header"andslot="aside"), every section a page and its filters the page's query, its whole suite passing, and a production build seeded the same way matching it section by section. Kanban is on it, and deployed (2026-10-17): the board and what the store sees are pages (/,/store) in a layout with its sign and history line, its cards actions (renamed in place with<soda-inline>andwith_detail, deleted with Undo in Soda's toast), Soda's gates, start screen, notices and sheets in its look; its 24 browser checks pass, and its screens match production's, but for the start screen's placeholders. - Backends (§16). Done 2026-10-17: the framework apart from the engine,
soda-localone backend of it andsoda-serveranother, each with its template (templates/local,templates/server) and suites;soda devon both, patching a running server as it does an instance;soda newmaking either; tutorial.md on the server, building the guestbook, and local-first.md on the local backend. - After. The space in the URL: done 2026-10-17 (§4; the local template's suite opens two
tabs on two lists, each acting on its own, a reload keeping each, an address naming none put
under the list opened last, the menu's lists links, and the service worker stopping and
starting again under a tab that has moved since it opened). View transitions between pages: done
2026-10-17, on both backends (a check each in the local template's and the server edges'
suites: a link to another page is one, a tab within the page and a move with motion turned down
aren't). Pre-rendered public pages: done 2026-10-17 (
#[prerender], the local template's/privacy: its HTML read before anything has run, and drawn for a browser with no account). Soda's own sheets in the address: done 2026-10-18 (§4; the local template's suite opens Share in one of two tabs on one list, a reload keeping it and Back closing it; Security's wipe confirmation cancels back to Security; closing Security stops offering a device; and a new browser's link to add it, opened on a device of the account, opens Security in that page).
14. Decisions taken here#
- JSX-like
view!over maud, with maud kept. Most people arriving know JSX or Svelte's markup; maud's syntax is the first thing that would make them hesitate. Both produce the sameView, so nothing has to be rewritten at once. - Svelte's directives (
on:,bind:,class:,show=) for Datastar's attributes: they read naturally, and they map one to one onto what Datastar already does. Aclass:name is any class Tailwind has, read off the tokens before the markup parser sees them. cnread off the compiled stylesheet, notdefault:classes. The first plan gave a component's own classes zero specificity (:where(), through adefault:variant) so that any class beside them won. Building it showed the costs: every utility a component uses becomes a second rule in the stylesheet,default:has to come last among a class's variants or it stops working, and it does nothing for classes handed to a Rocket component's part at run time. Reading what each utility sets off the stylesheet the app already compiles gives an exact merge, the same in the instance and the page, with nothing to keep in step with Tailwind.- Routes read by a procedural macro, with
build.rsonly to notice a new folder. The first plan hadbuild.rswrite the router, since Cargo reruns a build script when the folder changes and can't do that for a macro. But a hot patch compiles withrustcalone, and only what the compiler expands is current then; so the macro reads the folder, andbuild.rsjust tells Cargo when to compile again. - The app is a program (
src/main.rs), because dx patches the program it builds and builds only programs. Its release build runs Cargo and the pinned wasm-bindgen CLI, as wasm-pack would for a library. - Actions and downloads named in CamelCase in a view (
AddDish,Remove(id)), found porting Potluck: a lowercase value is a struct, and Rust reads a local variable of the same name as a pattern matching it. - A module per folder, named for it, so a route's files are where its folder says
(
routes::items::item::actions), and itsParamsaresuper::Paramsfrom any of them. - Params as text. Soda's ids are text; a page parses what it needs and returns
NotFoundwhen it can't, which keeps the generated code free of the app's types. - Actions are thin, the model decides. Keeping the rules in
decideandevolveis what letssoda_local::checktest them against hostile members, and what makes every instance agree. - The name and the place: Soda, in this repository beside the engine, so the two move together; an app depends on both.
15. Open questions#
Soda.toml, or keep the app's name and nouns in Rust (App::NOUN) and the shell, as now.- How much of Datastar to show:
on:clicktaking a Datastar expression is powerful and easy to misuse.on:takes only an action or a literal (text from data can't reach it), andHandler::rawis the one way to build an expression in code.
16. Backends: local-first is one way to run a Soda app#
Status: built, 2026-10-17 (the order of work, below, says what each step took).
Soda started as the way to write apps on the local-first engine, and everything above assumes it.
But what makes an app pleasant to write (routes as folders, view!, components, typed actions and
links, soda dev) has nothing to do with where its data lives, and Rust and Datastar both need a
framework people pick up without first buying an architecture. So the framework stands on its own,
and where an app runs is its backend:
soda-local, the engine this repository started as. The app runs in each person's browser, in its service worker; its data is a signed, encrypted log per space, synced through a store that can't read it; an action decides events; undo, presence, sharing, passkeys and devices come with it. No server to run.soda-server, the one most people will start with. The app runs on a server (axum); its data is whatever it keeps (SQLite or Postgres through sqlx, or memory); an action is an async function with the request's session. The first paint is the server's own HTML (search engines read it), and every open page holds one Datastar stream that the server draws it again on.soda-static, a server that runs once (built 2026-10-18). Every page is drawn as the site is built, into files any host serves; what the pages read is loaded once, then (Markdown, data). It has no actions. sodakit.dev is one, its docs this repository's (site/). See §16.1.
What stays the same is the model: a page is a function of what it reads and its address, an action changes things, and every open page draws itself again. On the local backend that's the engine folding events; on the server it's the server drawing every open page after an action (or when the app says something changed), Datastar morphing what differs. Either way there are no loaders, caches or client state to keep in step.
Where the line is#
In soda (any backend) | In soda-local | In soda-server | In soda-static |
|---|---|---|---|
Routes, layouts, not_found, error, head | The model (App: decide, evolve, undo) | The app's state (App: any Send + Sync type, made by start: a pool, a cache, memory) | What it's made of (App: any Send + Sync type, made by load as it's built; WATCH, what it's read from) |
view!, #[component], the components, cn | Spaces, members, keys, sync, the store | Sessions (a random id and its memory, in a cookie the server seals); who's signed in is the app's | Nothing per reader: a page is the same for everyone |
#[action], #[derive(Form)], #[download], Redirect, NotFound | act(command), act_seen, say_with_undo | Which open pages are drawn again: cx.listen(topic) in a page, cx.notify(topic) in an action (every page, without them) | No actions (refused, saying why); each download built once, into a file |
Typed links, cx.query, cx.choice, cx.filters, cx.here_with | Device memory sealed with the device's key | Memory per session | Which pages a route with params has: its page's params |
Page, Day, Href, Handler, the Datastar events (go, clear, done, errors) | The gates, the start screen, the bar and its parts, notices, Share and Security | The shell's first paint, the stream, the POST and GET routes, the app's own routes (App::routes) | Every page into the shell, 404.html, the sitemap (url); under soda dev, a server with a stream per page |
soda.js: moving between pages, forms and their errors, query fields, keys, dialogs | soda-local.js: passkeys, the worker, direct connections, storage | Nothing more: soda.js is all a server page needs | Nothing more: on a page built ahead of time (#app[data-static]), moving is the browser's |
soda no longer depends on the engine; each backend depends on soda and implements its seam.
16.1. Built ahead of time (soda-static)#
A static site is a server that runs once: the same routes, pages, layouts and components, drawn for every address as the site is built rather than as each is asked for. Most of it is the other two backends' already:
- The router (
soda::__routes!(prelude, static),soda_build::Backend::Static) writes, beside the usual,__soda_paths(cx), every page's address (a route with params at each its page'sparams(cx) -> Vec<Params>gives; one without it is a compile error saying why), and__SODA_FILES, each download's. It refuses what can't be built: an#[action](nothing posts to a site of files), a download that takes arguments or sits under params, and#[prerender](every page is). Pages may beasync, as on a server. - A drawing says what it is (
soda::render::Drawn::status: found, not found, moved, failed), so the build fails on a page itsparamsnamed that isn't there,not_found.rsbecomes404.html, and a server answers a missing page with a 404. - A page's head is in its file.
soda::render::in_shell(every backend's now) puts a page in its shell with itsHead(title and description) in the shell's<title>,descriptionandog:tags, and with the site'surl, its canonical address. - In the browser,
#app[data-static]tellssoda.jsthere's no backend: links, Back and aRedirectare the browser's own. A page's crossfade is the CSS@view-transitionrule. - Under
soda devthe program serves its pages as a server would (dx runs it, as it runs a server's), each with a stream the program draws it again on as patches land (soda-server's listener) or as whatWATCHnames changes. The CLI treats it as a server (Backend::serves).
The seam#
/// This device's or this session's memory (`cx.recall`, a page's filters), and its clock.
/// `act.say(…)`, `act.remember(…)`.
They're the read and write halves, not functions of the backend, because a server's memory is the request's session: whatever a page or an action reaches goes through what it was handed.
Cx<'a, B, P>keeps what's the framework's (params,page, the link and query helpers,now,today,recall) and derefs toB::Read<'a>; the aliasapp!writes hidesB, so a page isfn page(cx: &Cx<Params>)on either.cx.bar(…),cx.notices(),cx.history()move tosoda-local'sRead, where they always belonged.Act<B, P>likewise: the framework's answer to the page (go,say,keep,clear,finish) and, throughDeref, the backend's way of changing things.- Errors:
Error::Refusedholds what its person is told (a boxed error, shown byDisplay);soda-localturns itsRefusedinto it, socx.act(…).await?reads as now. - Pages may be
async, on a server: a page, a layout, aheador a download that reads a database is anasync fn, and the router awaits it (a backend asks for that:soda::__routes!(prelude, async)). A server'sCxborrows nothing (it reads throughArcs, and holds its page's address), soasync fn page(cx: &Cx)names no lifetime. What a drawing keeps while it's drawn (its path, its forms' ids) follows the drawing as a future (soda::route::drawing_async), whichever thread polls it. On the local engine, anasyncpage is an error saying why: what it reads is already on the device. - The backend's
app!.soda_local::app!(model::Items)orsoda_server::app!(state::App): each callssoda::__routes!(its prelude)for the router, which writes__soda_draw(cx),__soda_dispatch(path, body, write)and__soda_download(path, cx)(and the root layout'sintroandbarfor a frame that has them), and wires them to how that backend is served: theSitethe service worker runs, or an axumRouterwith the shell, the stream and the action and download routes. The backend builds eachCxandAct; the router only matches and draws. - Testing without a backend:
soda::backend::Bare, whose pages read only their address and whose actions only answer the page, is what Soda's own tests run on.
Serving, on the server#
As built (server/, soda-server; the example is examples/guestbook,
its browser suite npm run test:server):
GET /any/path: the shell (web/app.html) with#appdrawn for that address, marked with the page's id (data-page), and its stream's address naming the page and where it started. A new browser gets its session: a random 128-bit id and its memory (cx.remember,cx.recall, a page's filters), kept by the server and in a cookie it seals with its key (soda-session, an HMAC-SHA256 over both;HttpOnly,SameSite=Lax,Securebehind HTTPS, 400 days), sealed again whenever what it remembers changes. A server that has started again takes a browser back from its cookie as it was, with nothing stored. The key is in the app's data folder (SODA_DATA,data/beside the crate;/app/datain the image, a volume), made there the first time, orSODA_SECRET's; the folder is the app's too (soda_server::data(): its database's file, say), so what it keeps outlives the program.GET /api/stream?page=…&at=…: the page's Datastar stream. It draws the page at once, then again after every action (anyone's) and when the page moves (POST /api/page, whichsoda.jssends), sending nothing when it looks the same. A page the server doesn't know (it started again since) is drawn from where it started.POST /api/_/<route>/<action>: the same addresses and bodies as the local engine's (_args, a form's fields,_form), answered with the same Datastar events (soda-go,soda-done,soda-errors). What an action says (cx.say) or why it was refused is shown on its session's pages as a toast (<soda-say>). Only a request a page of the site made gets in: it carries Datastar's header orX-Soda, which a form on another site can't send.GET /api/_/<route>/<download>: the download, saved (or, a page, shown in place).- Anything else that's a file: the built site,
dist/(scripts/build-server.shwrites it: the app's web files, Soda in the page, the stylesheet and its class map). An address with an extension that isn't one is missing (404), as a host has it for a local-first app. - The app's own routes, beside its pages (
App::routes: axum'sRouter, with the app's state): a webhook, a feed, a health check. A handler that changes what pages show takesPagesand says so (pages.changed(),pages.notify(["book"])). - Which pages are drawn again: after an action, every open page, unless the action says what it
changed (
cx.notify("book")): then the pages listening for it (cx.listen("book")as they're drawn), the pages that never said, and the acting session's own (what it said is on them).
soda deploy makes a server app a container image: the app and the copy of Soda it builds
against, staged together (target/soda/deploy, its sources only: nothing built, fetched or
secret) and compiled inside the image's build with the toolchain the app pins, the result the
program and its site on a small base, serving on 8080, its data folder a volume (--image names
it, --push pushes it, --platform builds for another). It builds the same on any machine and
needs no key to fetch a private SDK. --to ssh://you@your-server (any host docker -H takes)
builds for that host's platform, loads the image there and runs it in place of the one before,
restarted if it stops, its data in a volume every deploy keeps: the database and every browser's
session outlive the deploy. Cargo's downloads and build are cached between deploys: after a one-line change to the
guestbook, the image builds again in 11 s.
soda dev runs the server under dx's hot-patching too (soda-server's hot feature, which it
turns on): dx launches the program and builds each patch against it as it runs, and the program
takes its own (it connects to dx, as a browser's worker does on the local backend), applies it,
and draws every open page again. Drawing, actions and downloads go through Subsecond's jump table,
so a patch reaches everything under them; a page that panics shows the panic in its place. In
front, soda dev keeps the pages' socket (styles, reloads, what went wrong) and passes everything
else to the program, waiting while it builds or starts again.
soda new makes either: on a server by default (templates/server, a checklist everyone who opens
it shares), or with --local (templates/local, the same checklist, local-first). The two
templates are the same pages on the two backends, which is the point of this section.
Order of work#
Each step leaves every app and suite passing, as Soda IR's and Care's moves did.
-
The split, changing nothing. Done 2026-10-17. What's general moved out of the engine into
soda(Page,Day, the post expressions, the Datastar events, ids, time words), the dependency is the other way round, the seam is in, andsoda-localis the backend the apps already used:soda_local::app!. Proof: every suite (Rust, edges, template, store,soda dev, Care, IR, Potluck, Pantry, Kanban), and the simulation's 300 lives (SIM_SEEDS=300 cargo test --release --features board sim_boards_survive) printing the same line per seed as before. -
The browser code apart. Done 2026-10-17.
soda-dom.js, what Soda's elements are made of;soda-elements.js, its components;soda.js, the page on any backend (moving between pages, query fields, forms and their errors, the stream, toasts), which a backend that sets up first claims (claim()); and the engine'ssoda-ui.js(its screens) andsoda-local.js(the worker, passkeys, sealed files, the store's check), which an app on it loads. Every suite on the local engine passes on it, and the server's page loadssoda.jsalone. -
soda-server: done 2026-10-17. The crate, the guestbook (two people sign, each page drawn again on its stream as the other does, a note taken back and another's refused, Back, a download, a remembered name, a deep link the server draws, an address it doesn't have), and its 13 browser checks, in Chromium and WebKit.soda devon it (tests/dev-server.test.mjs, 9 checks: patches, panics, styles, a new route, mistakes, the shell, a type's shape), andsoda newmaking it fromtemplates/server(10 browser checks of its own; an app made bysoda newpassessoda testas it is). -
The docs: done 2026-10-17. §1's aim for two backends, tutorial.md building the guestbook on the server, local-first.md building Pantry on the local backend (what it gives, what it asks: rules in a model, nothing secret in the app), and the README.
-
What a server app grows into: done 2026-10-17.
asyncpages, layouts, heads and downloads; narrowed redraws (cx.listen,cx.notify); the app's own routes (App::routes,Pages); andsoda deployas a container image.tests/apps/serveris where they meet an app's own code, andnpm run test:server-edgesits 8 browser checks (a form drawn after its page waited posts to its action, a page listening for something else isn't drawn again, a webhook redraws only what listens for it, an awaited head and download, a missing file a 404); the guestbook's image builds and serves. -
What it keeps, kept: done 2026-10-17. Sessions sealed into their cookies and taken back by a server that started again; the app's data folder; the server template and the guestbook on SQLite (sqlx,
migrations/,asyncpages and actions, the database's errors said in the app's words);App::startthat can fail, saying why; andsoda deploy --toany Docker host. The guestbook's suite starts its server again partway (14 checks: the book as it was, each browser still the writer of its own notes),soda devkeeps the book through a whole build, and a deploy to a Docker host twice keeps a note and its writer.
This page is docs/framework.md in the SDK, built into this site as it is.