Tutorials
Tutorial: a guestbook, from soda new to running
This builds an app on Soda: Guestbook, one book that anyone who opens it can sign. Each note
shows for everyone at once, its writer can take it back, and the whole book downloads as text. The
finished app is in examples/guestbook, kept building and passing its
browser tests with the SDK: when this page shows a file, that's the file.
It runs on Soda's server backend: the app is a program (axum underneath) that draws every page from what it keeps. The first paint is the server's own HTML, so a page reads with no script at all, and every open page holds one stream that the server draws it again on after any action, anyone's. A page is a function of what it reads and its address; there are no loaders, caches or client state to keep in step.
If you've used Next.js or SvelteKit, you know where everything is: a folder under src/routes is
an address, a page.rs draws it, forms post to actions written beside it, and markup is
JSX-like (view!). framework.md is the design behind it. Soda's other backend runs
an app in each person's browser instead, local-first: local-first.md builds one,
with the same pages.
What you need: Rust (with rustup: each app pins its toolchain) and Node 22.
1. A new app#
Install the SDK's CLI from a checkout of it, once:
Then make the app, and serve it:
It starts as the SDK's server template: a checklist that everyone who opens it shares, kept in
SQLite, which builds and passes its tests as it is. Open the address soda dev prints in two windows, and add an
item in one: it's in the other at once, drawn again on its stream. Now change something in
src/routes/layout.rs (the words under "Guestbook", say) and save. Both windows show it in about a
second, without reloading, and the list is still there: soda dev runs the server under dx, which
patches the change into it as it runs, so what it holds is kept (hot-patching.md).
What's there:
| Where | What |
|---|---|
src/list.rs | What the server keeps: the list, in SQLite (data/checklist.db) |
migrations/ | The database's schema, a file per change, run as the server starts |
src/routes/ | The pages: a folder is an address, page.rs draws it, actions.rs is what its forms post |
src/components.rs | What the pages share, as #[component]s |
src/main.rs | The program: soda_server::app! names what the server keeps |
web/app.html, web/app.css | The page shell, and the look: design tokens everything is drawn with |
tests/e2e.mjs | Real browsers, two people at once, end to end |
The guestbook won't need the checklist's own parts: delete src/list.rs, src/components.rs,
src/routes/items/, migrations/0001_items.sql, and web/components/ (and its import in
web/kit.js). data/ is where the server keeps what it keeps (its database, the key its
sessions are sealed with): delete it too, for a fresh start.
2. What the server keeps#
An app on a server names one type, its state: made once as the server starts, and shared by
every request after (so it's Send + Sync). The guestbook's is the book: a connection pool to its
SQLite database, in the app's data folder (soda_server::data(): data/ beside the crate, or
SODA_DATA), which outlives the program. The schema is migrations/0001_entries.sql:
-- The book's entries: who signed, what they wrote, when (ms since 1970), and which session wrote it
-- (so it can take its note back).
(
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
text TEXT NOT NULL,
at INTEGER NOT NULL,
by_session TEXT NOT NULL
);
ON entries (at);and src/book.rs is the state, and every function the pages and actions call:
//! The book: its entries, newest last, kept in SQLite in the app's data folder (`guestbook.db`), so
//! they outlive the server: a restart, a deploy, a whole build under `soda dev`. Its schema is
//! `migrations/`, run as it opens. What's wrong with the database is said in the book's words (the
//! server's log has the rest), so a page or an action takes it with `?`.
use Path;
use ;
/// One signature: who, what they wrote, when, and which session wrote it (so it can take it back).
/// The most a book keeps: the oldest go once it's full.
pub const MAX_ENTRIES: i64 = 500;
/// What a page or an action says when the database fails it: the book's words, not the database's.
It's plain Rust and plain SQL (sqlx, which the template uses): the pages
and actions only call entries, sign and take_back. Each says what's wrong with the database
in the book's own words (unavailable), as a soda::Error, so a page or an action takes it with
? and its person reads something they can act on. start returning an error stops the server,
saying why. Below the code (in the file), the book's tests run against a database in memory, with
#[tokio::test]: it keeps the newest 500, and lets only a note's writer take it back.
src/main.rs names it:
//! A guestbook on Soda's server backend: one book, signed from any browser, drawn again for every
//! open page as anyone signs it. Its state is the server's (`book.rs`, in SQLite in its data
//! folder); each browser is a session, which remembers the name it signs with.
app!;app! reads src/routes as the crate compiles: the router, a typed link for every page
(link::about()), and the app's Cx, Act and prelude. It writes the program's main too,
which serves the app. Cargo.toml names sqlx (and tokio, for the tests); build.rs has Cargo build
again when a migration is added.
3. The page#
src/routes/page.rs is /: the form, and everyone's entries, newest first.
//! `/`: the book. Sign it (the name is remembered for next time), and read everyone's entries,
//! newest first; one's own can be taken back. Every open page is drawn again as anyone signs.
use ;
use Everything;
use crate*;
pub async The page is an async fn: it reads the database, and the server awaits it as it draws the page.
A page reads two things beyond its address: cx.state, the book, and cx.session, the browser
asking (each one gets a session, a cookie the server gives it). cx.recall("name") is what this
session remembered under name: the name it last signed with, filled in for next time. The
cookie holds the session too, sealed with a key the server keeps in its data folder, so a server
that starts again (a deploy, a restart) takes each browser back as it was.
In view!, text is in quotes, Rust is in braces, and an attribute's value is any expression.
Card, Field, Input, Textarea, Button, Empty and Time are Soda's components, drawn
with the app's design tokens (reference.md has them all); <Time at=…/> shows
"5 minutes ago" in the reader's own words and zone. head gives the page its title in the tab.
4. Signing: a form and its action#
A form names the action it posts to (action=Sign), and the action is written beside its page, in
src/routes/actions.rs:
//! What the book's page posts to: signing it, and taking one's own note back.
use crateEntry;
use crate*;
/// A note signed, under the name this browser remembers for next time.
pub async #[derive(Form)] reads the form's fields into Signing: a field that's missing, or longer than
its max, is shown under its field, and the action never runs. #[action] makes Sign, what the
page names, and gives the action its address (/api/_/sign).
Then it signs the book, remembers the name for this session, keeps the name field filled as the
form empties for the next note (keep), and says so: "Signed the book." shows as a toast on this
browser's pages. Once an action is done, every open page is drawn again from the book as it is
now, so in the other window the note is there already, with nothing written to make it so.
5. Whose note is it#
Each entry keeps the session that wrote it (by). The page shows "Take back" only on one's own
(e.by == cx.session.id), but a page is only what it shows: anyone can post to an action, so the
action checks it too.
/// One's own note, taken back.
pub async A button's action takes its values as arguments: on:click=TakeBack(e.id.clone()) posts the id,
and take_back gets it as entry. An Err with words is a refusal: its person is told, in those
words, and nothing changes. Soda refuses a request from another site before any action sees it (a
page of the app sends a header a form elsewhere can't).
6. Another page, and around them all#
A folder is an address: src/routes/about/page.rs is /about.
//! `/about`: what this is.
use crate*;
src/routes/layout.rs is drawn around every page, with the pages as tabs:
//! Around every page: the book's name, and its two pages as tabs.
use crate*;
link::about() is the page's typed link: a page that's renamed or removed is a compile error at
every link to it, never a broken one. cx.link adds whether it's the page you're on, which the tab
shows. A link moves the page in place: the stream draws it at its new address, and Back comes
back. src/routes/not_found.rs is what an address the book doesn't have shows (the template's,
reworded).
7. A download#
src/routes/downloads.rs:
//! The book, to keep: every entry as text.
use crate*;
pub async A download reads what a page does, and the page links to it like any page (href=Everything download, in §3). Download::page is one shown in place instead, for printing.
8. The look#
web/app.css is the app's design tokens: every color, font, radius and shadow the views use
(bg-surface, text-muted, rounded-card), and the build refuses any that isn't one. The
guestbook warms the accent:
@}Save it with soda dev running and the open pages take the new sheet in place, in a fifth of a
second. styling.md has the tokens and Soda's classes.
9. While it runs#
What soda dev does with each change, on the server:
| You change | What happens |
|---|---|
| A page, a layout, a component, an action | Patched into the running server in about a second; every open page is drawn again from the new code, and the book, the sessions and the open pages are all still there |
| A new route (a folder, a file) | Patched in the same way: dx is told of the file |
The shell (web/app.html) | The pages reload into it: the server reads it from disk while soda dev runs it |
web/app.css | Swapped into the open pages in place |
A type's shape (a field on Book) | The server is built whole and starts again, and the pages reload: the book is still there (it's in the database), and so is each session |
| A mistake | It doesn't build, or a file doesn't parse: said over the page, with where, until it's fixed. A page that panics shows the panic in its place, with where, and is drawn again once it's fixed |
What the app prints (println!) is in soda dev's terminal.
10. Testing#
The guestbook's tests/e2e.mjs runs the built program on a free port and drives two people, one in
Chromium and one in WebKit: the first paint is the server's HTML; Ada signs, and Bob's open page
shows it without reloading; Ada takes her note back, and Bob can't take back hers; the About tab
moves the page in place and Back returns; the download is the book; a reload keeps the remembered
name; and the server starts again partway (a deploy), the book as it was and each browser still
the writer of its own notes. It gives the server a data folder of its own (SODA_DATA), so each
run starts with an empty book. Read it for the patterns (waiting on what the page shows, never on
time).
11. Running it, and shipping it#
That program, dist/ and a data folder are all a host needs: run it with SODA_ADDR set to the
address it gives (0.0.0.0:8080, say), SODA_DATA where it keeps its data, and SODA_DIST if
dist/ is somewhere else. soda deploy makes it a container image, and puts it on a server:
It stages the app and the copy of Soda it builds against, and compiles them inside the image's
build with the toolchain the app pins, so it builds the same anywhere and needs no key to fetch the
SDK. The image is the program and its site on a small base (about 150 MB), serving on 8080, its
data folder a volume. --to takes any host Docker reaches (ssh://…, tcp://…): it 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 (guestbook-data) that every deploy keeps, so the book and every
browser's session outlive it. A later deploy builds only what changed: 11 s after a one-line
change to a page, the first one some minutes.
12. As it grows#
Three things a guestbook doesn't need yet, and a bigger app will:
- More of a schema. A change to the database is a new file in
migrations/(0002_entries_by_name.sql), run once as the server starts; a page, a layout, aheador a download that reads the database is anasync fn, as the book's page is. - Drawing fewer pages again. Every open page is drawn again after every action, which is
right until it's a lot. A page says what it shows (
cx.listen("book")), an action what it changed (cx.notify("book")), and then only the pages listening for it are drawn again (and the acting session's own). - Routes of its own. A webhook, a feed, a health check:
App::routesreturns axum'sRouterwith the app's state, and a handler that changes what pages show takesPages(pages.notify(["book"])). reference.md has an example.
Where next#
- reference.md: every component,
view!, routes, actions, forms,Cx, the CLI. - framework.md: Soda's design, and why (§16 is the two backends).
- local-first.md: the same framework on the local-first backend, building Pantry.
- hot-patching.md: what
soda devdoes as you save. - styling.md: the look.
This page is docs/tutorial.md in the SDK, built into this site as it is.