Project 6: Full-Stack App (Axum API + WASM frontend)
This capstone builds a complete full-stack application entirely in Rust: an
axum JSON API on the backend and a
wasm-bindgen + web-sys single-page
frontend that compiles to WebAssembly, fetches from that API, and updates the
DOM by hand. It is a small notes app (create a note, list notes, delete a
note), but it exercises the full shape of a real product: a typed HTTP API, an
in-memory data store shared across concurrent requests, JSON (de)serialization
on both ends, and a browser client written in the same language as the server.
If you come from Node, this is the Rust analogue of an Express (or Fastify)
API plus a vanilla-JS / React SPA living in one monorepo. The twist is that
both halves are Rust, and they even share the same serde data shapes
conceptually. The backend compiles to a native binary; the frontend compiles to
a .wasm module the browser loads as an ES module. We wire them together as a
single Cargo workspace, Cargo’s answer to npm/pnpm workspaces.
Built and verified with: Rust 1.96.0 (2024 edition), a local
rustc/cargo1.96.0 toolchain,axum0.8.9,tokio1.52,tower-http0.6,tracing0.1,serde/serde_json1.0,wasm-bindgen0.2.122,wasm-bindgen-futures0.4,js-sys/web-sys0.3,gloo-net0.7, andwasm-pack0.13.1. Thewasm32-unknown-unknowntarget is installed viarustup target add wasm32-unknown-unknown. Every command, log line, and HTTP response shown below was produced by actually building and running the code infull-stack-code/.
What You’ll Build
Section titled “What You’ll Build”A two-crate workspace serving one app:
| Piece | Crate | Compiles to | Role |
|---|---|---|---|
| Backend API | backend | native binary | GET/POST/DELETE JSON over HTTP; serves the static frontend too |
| Frontend SPA | frontend | wasm32 .wasm | fetches the API and renders/mutates the DOM in the browser |
The JSON API has three endpoints:
| Method & path | Body | Response | Node/Express analogue |
|---|---|---|---|
GET /api/notes | — | 200 + Note[] (newest first) | app.get('/api/notes', ...) |
POST /api/notes | { "title", "body" } | 201 + the created Note, or 400 { "error" } | app.post('/api/notes', ...) |
DELETE /api/notes/{id} | — | 204, or 404 { "error" } | app.delete('/api/notes/:id', ...) |
The same backend process also serves index.html and the compiled wasm bundle
at /, so in development you run one command and open one URL: no separate
static server, no CORS dance.
In the browser the page looks like this (described in words, since it is a live
DOM): a heading “Notes”, a small form with a Title input, a Body
textarea, and an Add note button, then a status line, then a list of note
cards. Each card shows the title (<h3>), the body (<p>), a locale-formatted
timestamp (<small>), and a Delete button. Clicking Add note POSTs the
form and re-renders the list; clicking Delete removes that note and
re-renders. On first load the list already contains one seeded “Welcome” note
that came from the Rust backend.
A real request/response pair from the running server:
$ curl -s -X POST http://127.0.0.1:3000/api/notes \ -H 'Content-Type: application/json' \ -d '{"title":"Buy milk","body":"2 liters, oat"}'{"id":2,"title":"Buy milk","body":"2 liters, oat","created_at":1780384172457}Prerequisites
Section titled “Prerequisites”This project ties together threads from across the guide. If any concept below feels shaky, the linked section has the full treatment:
- 05 — Ownership: why the shared store is
Arc<Mutex<HashMap<..>>>and not just a global variable. - 08 — Error Handling:
Result, the?operator, and returning typed HTTP errors instead of throwing. - 09 — Generics & Traits: axum extractors and
IntoResponse;JsCastin the browser. - 11 — Async:
async/await, the Tokio runtime, andspawn_localfor browser futures. Remember: Rust futures are lazy and need a runtime to drive them, unlike eager JavaScript Promises. - 15 — Serialization: the
serdederives that turn structs into JSON and back. - 16 — Web APIs: axum routing, handlers, state, and middleware in depth. This project is the “now build the whole thing” follow-up.
- 19 — WebAssembly:
wasm-bindgen,web-sys, andwasm-packfundamentals.
You should also have the wasm target installed:
rustup target add wasm32-unknown-unknownand wasm-pack (cargo install wasm-pack, or via the official installer).
Project Structure
Section titled “Project Structure”The code lives in full-stack-code/ as a Cargo workspace:
full-stack-code/├── Cargo.toml # workspace root: members + shared deps + release profile├── build.sh # convenience: build wasm into static/pkg, then build backend├── .gitignore # ignores /target and the regenerated /static/pkg├── backend/│ ├── Cargo.toml # native crate: axum, tokio, tower-http, tracing, serde│ └── src/│ ├── main.rs # entry point: boot Tokio, build router, serve on :3000│ ├── models.rs # Note / CreateNote / ApiError — the JSON shapes│ ├── state.rs # AppState: Arc<Mutex<HashMap>> in-memory store│ ├── handlers.rs # the three async request handlers│ └── router.rs # routes + static file serving + CORS + trace middleware├── frontend/│ ├── Cargo.toml # wasm crate: cdylib, wasm-bindgen, web-sys, gloo-net│ └── src/│ ├── lib.rs # #[wasm_bindgen(start)] entry; render + event wiring│ ├── api.rs # gloo-net fetch wrappers (the API client)│ ├── dom.rs # typed web-sys DOM helpers│ └── models.rs # client-side mirror of the backend JSON shapes└── static/ ├── index.html # the page shell; loads the wasm ES module └── pkg/ # GENERATED by wasm-pack (frontend.js + frontend_bg.wasm)Why two crates, not one? The backend and frontend compile for different targets: your machine’s native triple versus
wasm32-unknown-unknown. A single crate can’t be both. A workspace lets them share aCargo.lock, dependency versions, and onecargoinvocation, exactly like a pnpm workspace shares one lockfile across packages.
Walkthrough
Section titled “Walkthrough”We’ll build from the inside out: the workspace, then the data shapes, the store, the handlers, the router, the binary, and finally the WASM client.
Step 1 — The workspace root
Section titled “Step 1 — The workspace root”The root Cargo.toml declares the two member crates and a couple of shared
dependency versions. It also sets a size-optimized release profile, which is
what we want for the wasm bundle.
# Workspace root: ties the backend (native) and frontend (wasm) crates together.# Think of this as the monorepo `package.json` "workspaces" field, but for Cargo.[workspace]resolver = "3"members = ["backend", "frontend"]
# Shared dependency versions live here so both crates stay in sync.# Each crate opts in with `serde = { workspace = true }`, etc.[workspace.dependencies]serde = { version = "1.0.228", features = ["derive"] }serde_json = "1.0.150"
# A release profile tuned for small WASM output (used by the frontend crate).[profile.release]opt-level = "z" # optimize for size, not speed (good default for wasm bundles)lto = true # link-time optimization trims dead code across cratesresolver = "3" is the dependency resolver that ships with the 2024 edition.
The [workspace.dependencies] table is the Cargo equivalent of pinning a
version once in a monorepo and having every package reference it; below you’ll
see each crate write serde = { workspace = true }.
Node contrast: in a pnpm/npm workspace you’d hoist
serde’s analogue into the rootpackage.jsonand let workspace protocol resolve it. Same idea, but Cargo also lets you set a shared build profile here; there’s no npm equivalent of “optimize every package for size.”
Step 2 — The data shapes (backend/src/models.rs)
Section titled “Step 2 — The data shapes (backend/src/models.rs)”These three structs are the API’s contract. serde’s derive macros generate the
JSON (de)serialization: no hand-written JSON.parse/JSON.stringify, and no
runtime validation library like zod, because the types are the schema.
//! Data shapes shared across the API, with `serde` derives so they//! serialize to/from JSON. The TypeScript analogue would be a set of//! `interface`s plus hand-written (or `zod`-validated) parsing.
use serde::{Deserialize, Serialize};
/// A single note. `Serialize` turns it into JSON for responses;/// `Clone` lets us hand copies out of the in-memory store.#[derive(Debug, Clone, Serialize)]pub struct Note { pub id: u64, pub title: String, pub body: String, /// Unix-millis timestamp; the frontend formats it for display. pub created_at: u128,}
/// The request body for creating a note. Only `Deserialize` —/// clients send these fields; the server assigns `id`/`created_at`.#[derive(Debug, Deserialize)]pub struct CreateNote { pub title: String, pub body: String,}
/// A uniform error envelope so the frontend can always read `error`.#[derive(Debug, Serialize)]pub struct ApiError { pub error: String,}Notice the asymmetry that TypeScript can’t express as cleanly: Note is
Serialize (it leaves the server) while CreateNote is Deserialize (it enters
the server). The compiler enforces that you never accidentally accept a
client-supplied id or created_at; those fields simply don’t exist on the
input type. In Express you’d reach for a separate DTO/validation schema to get
the same guarantee.
Step 3 — The in-memory store (backend/src/state.rs)
Section titled “Step 3 — The in-memory store (backend/src/state.rs)”No database is required to compile or run this. The store is a HashMap behind
an Arc<Mutex<..>>. In Node you might write const notes = new Map() at module
scope and mutate it freely, because there’s a single thread and event loop. Rust
makes the sharing explicit: Arc for shared ownership across async tasks,
Mutex for safe concurrent mutation, AtomicU64 for a collision-free id
counter.
//! The in-memory data store. No database required to compile or run.//!//! This is the Rust analogue of a module-level `const notes = new Map()`//! in a Node app — except sharing it across concurrent requests is//! explicit: `Arc` for shared ownership across tasks, `Mutex` for safe//! mutation. See ../../../17-database/README.md for swapping in real SQL.
use std::collections::HashMap;use std::sync::atomic::{AtomicU64, Ordering};use std::sync::{Arc, Mutex};use std::time::{SystemTime, UNIX_EPOCH};
use crate::models::{CreateNote, Note};
/// Shared application state. `axum` clones this (cheap — it's just `Arc`s)/// for every request handler that asks for it via `State<AppState>`.#[derive(Clone)]pub struct AppState { /// id -> Note. `Arc<Mutex<..>>` = "shared, mutable, thread-safe". notes: Arc<Mutex<HashMap<u64, Note>>>, /// Monotonic id generator. Atomic so we never hand out a duplicate. next_id: Arc<AtomicU64>,}
impl AppState { /// Build a store pre-seeded with one welcome note, so the UI has /// something to render on first load. pub fn new() -> Self { let state = Self { notes: Arc::new(Mutex::new(HashMap::new())), next_id: Arc::new(AtomicU64::new(1)), }; state.create(CreateNote { title: "Welcome".to_string(), body: "This note came from the Rust backend.".to_string(), }); state }
/// Return every note, newest first. pub fn list(&self) -> Vec<Note> { let guard = self.notes.lock().expect("notes mutex poisoned"); let mut notes: Vec<Note> = guard.values().cloned().collect(); notes.sort_by(|a, b| b.created_at.cmp(&a.created_at)); notes }
/// Insert a new note and return the stored copy (with server fields). pub fn create(&self, input: CreateNote) -> Note { // `fetch_add` returns the *previous* value and bumps the counter // atomically, so concurrent creators never collide. let id = self.next_id.fetch_add(1, Ordering::Relaxed); let note = Note { id, title: input.title, body: input.body, created_at: now_millis(), }; self.notes .lock() .expect("notes mutex poisoned") .insert(id, note.clone()); note }
/// Remove a note by id; `true` if something was actually deleted. pub fn delete(&self, id: u64) -> bool { self.notes .lock() .expect("notes mutex poisoned") .remove(&id) .is_some() }}
/// Current time as Unix milliseconds (like JS `Date.now()`).fn now_millis() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) .expect("clock before 1970") .as_millis()}A few things worth pausing on:
#[derive(Clone)]onAppStateis cheap: cloning anArcjust bumps a reference count, it does not copy theHashMap. Every request handler gets a clone of the handles, all pointing at the same store..lock()returns aResultbecause the mutex could be poisoned (a thread panicked while holding it). We.expect(..)here for brevity; production code would handle it. There’s noawaitwhile the guard is held, so we use the standard-libraryMutex, not Tokio’s async one.fetch_add(1, Ordering::Relaxed)is a lock-free atomic increment: the id source can’t hand out duplicates even under concurrentPOSTs.
Swapping in a real database: replace
AppState’s internals with a connection pool (e.g.sqlx::SqlitePoolorPgPool) and make these methodsasync. The handler signatures barely change. See 17 — Database for the full pattern, including in-memory SQLite for tests.
Step 4 — The handlers (backend/src/handlers.rs)
Section titled “Step 4 — The handlers (backend/src/handlers.rs)”Each handler is an async fn whose return type is the response. axum’s
extractors (State, Json, Path) pull typed data out of the request before
your code runs, the equivalent of Express middleware that parses req.body,
req.params, etc., except failures are handled by the framework and the types
are checked at compile time.
//! Request handlers — the equivalent of Express route callbacks//! `(req, res) => { ... }`, but each is a typed async function whose//! return type *is* the response.
use axum::{ Json, extract::{Path, State}, http::StatusCode, response::{IntoResponse, Response},};
use crate::models::{ApiError, CreateNote, Note};use crate::state::AppState;
/// `GET /api/notes` -> `200` with a JSON array of notes.pub async fn list_notes(State(state): State<AppState>) -> Json<Vec<Note>> { Json(state.list())}
/// `POST /api/notes` -> `201` with the created note.////// `Json(payload)` is an extractor: axum parses and validates the body/// against `CreateNote` before this function runs. A malformed body/// never reaches us — axum returns `422` automatically.pub async fn create_note( State(state): State<AppState>, Json(payload): Json<CreateNote>,) -> Response { // A little hand-rolled validation to show returning a typed error. if payload.title.trim().is_empty() { return ( StatusCode::BAD_REQUEST, Json(ApiError { error: "title must not be empty".to_string(), }), ) .into_response(); } let note = state.create(payload); (StatusCode::CREATED, Json(note)).into_response()}
/// `DELETE /api/notes/{id}` -> `204` if deleted, `404` otherwise.////// `Path(id)` pulls the `{id}` segment from the URL and parses it into a/// `u64`. axum 0.8 uses `{id}` syntax (not the old `:id`).pub async fn delete_note( State(state): State<AppState>, Path(id): Path<u64>,) -> Response { if state.delete(id) { StatusCode::NO_CONTENT.into_response() } else { ( StatusCode::NOT_FOUND, Json(ApiError { error: format!("no note with id {id}"), }), ) .into_response() }}Two important framework details:
- Automatic body validation. Because
create_notetakesJson<CreateNote>, axum returns422 Unprocessable Entityon its own if the JSON is malformed or missing fields; that branch never reaches your function. Our explicit400is additional business-rule validation (non-empty title). - axum 0.8 path syntax. The dynamic segment is
{id}, not the:idused by older axum and by Express.Path<u64>even parses the string to a number for you; a non-numeric id yields a400automatically.
IntoResponse is the trait that lets us return different concrete types and have
them all turn into an HTTP Response. Tuples like (StatusCode, Json<T>)
implement it, which is why (StatusCode::CREATED, Json(note)).into_response()
just works.
Step 5 — The router and static serving (backend/src/router.rs)
Section titled “Step 5 — The router and static serving (backend/src/router.rs)”This is the app.use(...) / app.get(...) section, assembled as a value we can
build and (in a larger app) unit-test. It nests the API under /api, serves the
compiled frontend from static/ as a fallback, and layers on CORS and request
tracing.
//! Wiring: maps URL patterns to handlers and attaches middleware.//! This is the `app.use(...)` / `app.get(...)` section of an Express app,//! but assembled as a value you can return and test.
use axum::{Router, routing::get};use tower_http::cors::CorsLayer;use tower_http::services::ServeDir;use tower_http::trace::TraceLayer;
use crate::handlers::{create_note, delete_note, list_notes};use crate::state::AppState;
/// Build the full application router.////// `static_dir` is served at `/` so the same process can hand out the/// compiled WASM frontend AND the JSON API — no separate static server/// needed in dev. In production you'd usually put a CDN in front.pub fn build_router(state: AppState, static_dir: &str) -> Router { // The JSON API, nested under /api. let api = Router::new() .route("/notes", get(list_notes).post(create_note)) .route("/notes/{id}", axum::routing::delete(delete_note)) .with_state(state);
Router::new() .nest("/api", api) // Serve index.html + the wasm bundle from the static directory. .fallback_service(ServeDir::new(static_dir)) // Permissive CORS so you can also run the frontend from a // different dev port if you prefer. Tighten this in production. .layer(CorsLayer::permissive()) // Structured request logging, like `morgan` in Express. .layer(TraceLayer::new_for_http())}get(list_notes).post(create_note) attaches two methods to the same path,
exactly like chaining .get().post() in Express’s app.route(). ServeDir is
a ready-made tower service that serves files from a directory; using it as the
fallback_service means any request that didn’t match an API route (like / or
/pkg/frontend_bg.wasm) is answered from static/. The .layer(..) calls add
middleware that wraps every request, the way tower services compose.
Why serve the frontend from the API process? It keeps the dev loop to a single command and avoids CORS entirely (the page and the API share an origin). In production you’d typically serve the static assets from a CDN and point the frontend at the API’s URL; the permissive CORS layer is already here for that.
Step 6 — The binary (backend/src/main.rs)
Section titled “Step 6 — The binary (backend/src/main.rs)”The entry point boots the Tokio runtime, sets up logging, builds the router, and
serves. #[tokio::main] is the macro that turns an async fn main into a normal
one that starts the runtime first. Rust has no built-in event loop, so you opt
into one. (Contrast Node, where the event loop always exists.)
//! Backend entry point. Boots Tokio, builds the router, and serves.//!//! Node analogue://! ```js//! const app = express();//! app.listen(3000, () => console.log("listening"));//! ```
mod handlers;mod models;mod router;mod state;
use std::net::SocketAddr;
use tokio::net::TcpListener;use tracing_subscriber::EnvFilter;
use crate::router::build_router;use crate::state::AppState;
/// `#[tokio::main]` turns this async `main` into a sync one that starts/// the runtime first — Rust has no built-in event loop, you opt into one.#[tokio::main]async fn main() { // Logging. `RUST_LOG=info` (the default below) controls verbosity. tracing_subscriber::fmt() .with_env_filter( EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new("info,tower_http=info")), ) .init();
// Where the compiled frontend lives. Override with STATIC_DIR. // The default is resolved at build time from this crate's directory // (`CARGO_MANIFEST_DIR` = the `backend/` dir), so it is correct no // matter the current working directory `cargo run` was invoked from. let static_dir = std::env::var("STATIC_DIR") .unwrap_or_else(|_| concat!(env!("CARGO_MANIFEST_DIR"), "/../static").to_string());
let state = AppState::new(); let app = build_router(state, &static_dir);
let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); let listener = TcpListener::bind(addr) .await .expect("failed to bind to port 3000");
tracing::info!("backend listening on http://{addr}"); tracing::info!("serving static files from {static_dir}");
// axum 0.8: serve a listener + a router. No `app.listen` magic. axum::serve(listener, app) .await .expect("server error");}In axum 0.8 you bind a TcpListener yourself and hand it to axum::serve(..).
There’s no app.listen(port, cb); the listener and the router are separate
values you compose. The STATIC_DIR default uses
concat!(env!("CARGO_MANIFEST_DIR"), "/../static"): CARGO_MANIFEST_DIR is set
by Cargo at compile time to this crate’s directory (backend/), so the
default resolves to the project’s static/ as an absolute path baked into the
binary. That matters because cargo run -p backend runs the binary with the
working directory set to wherever you invoked cargo (the workspace root), not
the backend/ package dir. A plain "../static" relative path would point one
level above the workspace and 404. The build-time absolute path sidesteps the cwd
question entirely, so the running binary always finds the wasm bundle wasm-pack
produced. (Override it at runtime with the STATIC_DIR env var if you relocate the
assets.)
Step 7 — The backend Cargo.toml
Section titled “Step 7 — The backend Cargo.toml”[package]name = "backend"version = "0.1.0"edition = "2024"
[dependencies]axum = "0.8.9"tokio = { version = "1.52.3", features = ["full"] }tower-http = { version = "0.6.11", features = ["fs", "cors", "trace"] }tracing = "0.1.41"tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }serde = { workspace = true }serde_json = { workspace = true }The tower-http features map one-to-one to the middleware we used: fs for
ServeDir, cors for CorsLayer, trace for TraceLayer. serde is pulled
from the workspace so the version matches the frontend’s.
Step 8 — The WASM frontend
Section titled “Step 8 — The WASM frontend”Now the browser half. It’s a cdylib (a dynamic library the wasm toolchain
post-processes), and it depends on the wasm-specific crates.
[package]name = "frontend"version = "0.1.0"edition = "2024"
# `cdylib` produces a `.wasm` artifact that wasm-bindgen post-processes# into something the browser can load (like emitting an ES module).[lib]crate-type = ["cdylib"]
[dependencies]wasm-bindgen = "0.2.122"wasm-bindgen-futures = "0.4.72"js-sys = "0.3.99"gloo-net = { version = "0.7.0", features = ["json"] }serde = { workspace = true }serde_json = { workspace = true }console_error_panic_hook = "0.1.7"
# web-sys is feature-gated: you enable exactly the Web APIs you touch,# which keeps the generated bindings (and the wasm) small.[dependencies.web-sys]version = "0.3.99"features = [ "Document", "Element", "HtmlElement", "HtmlInputElement", "HtmlTextAreaElement", "Window", "Event", "MouseEvent",]web-sys is unusual: you enable the exact Web APIs you call as Cargo features.
Forget to list HtmlInputElement and the code won’t compile. This is the
mechanism that keeps wasm bundles lean: you only pay for the bindings you use.
The data shapes, mirrored (frontend/src/models.rs)
Section titled “The data shapes, mirrored (frontend/src/models.rs)”Because the two crates target different platforms, they can’t trivially share a
module, so the client keeps a small mirror of the JSON shapes. The field names
must match the backend’s serde output exactly. That is the wire contract.
//! Client-side mirror of the backend's JSON shapes.//!//! In a Node monorepo you'd share one `types.ts` between client and//! server. Here the two crates compile for different targets (native vs//! wasm), so we keep a small mirrored copy. The field names must match//! the backend's `serde` output exactly — that's the contract.
use serde::{Deserialize, Serialize};
/// A note as returned by `GET /api/notes`.#[derive(Debug, Clone, Deserialize)]pub struct Note { pub id: u64, pub title: String, pub body: String, pub created_at: u128,}
/// The body we send to `POST /api/notes`.#[derive(Debug, Serialize)]pub struct CreateNote { pub title: String, pub body: String,}Sharing for real: to avoid the mirror, you can extract these structs into a third
sharedcrate that bothbackendandfrontenddepend on, as long as it only uses#![no_std]-friendly, target-agnostic deps (serdequalifies). The mirror is shown here to keep the workspace to two crates and the moving parts visible.
The API client (frontend/src/api.rs)
Section titled “The API client (frontend/src/api.rs)”gloo-net wraps the browser fetch API in a Rusty async/Result interface,
the analogue of a small apiClient.ts. Every fallible step returns a Result
and ? short-circuits on the first error. There’s no try/catch: the type
system forces the caller to handle failure.
//! The API client layer. `gloo-net` wraps the browser `fetch` API in a//! Rusty, `async`/`Result` interface — the analogue of a small//! `apiClient.ts` built on `fetch`.
use gloo_net::http::Request;
use crate::models::{CreateNote, Note};
/// Base URL of the JSON API. Relative, so it works whether the page is/// served by the Rust backend itself or by any static host that proxies/// `/api` to it.const API_BASE: &str = "/api";
/// `GET /api/notes` -> deserialize into `Vec<Note>`.////// Every step that can fail returns a `Result`, and `?` short-circuits/// on the first error — no `try/catch`, the type system forces you to/// acknowledge failure. `gloo_net::Error` is the unified error type.pub async fn fetch_notes() -> Result<Vec<Note>, gloo_net::Error> { let notes = Request::get(&format!("{API_BASE}/notes")) .send() .await? .json::<Vec<Note>>() .await?; Ok(notes)}
/// `POST /api/notes` with a JSON body -> the created `Note`.pub async fn create_note(input: &CreateNote) -> Result<Note, gloo_net::Error> { let note = Request::post(&format!("{API_BASE}/notes")) .json(input)? // serialize body + set Content-Type .send() .await? .json::<Note>() .await?; Ok(note)}
/// `DELETE /api/notes/{id}` -> `Ok(())` on success.pub async fn delete_note(id: u64) -> Result<(), gloo_net::Error> { Request::delete(&format!("{API_BASE}/notes/{id}")) .send() .await?; Ok(())}Compare fetch_notes to its JavaScript twin:
// the rough Node/browser equivalentasync function fetchNotes(): Promise<Note[]> { const res = await fetch("/api/notes"); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); // unchecked: TS trusts you it's Note[]}In TypeScript res.json() returns any/Promise<any> and the Note[]
annotation is an unchecked assertion. In Rust, .json::<Vec<Note>>() actually
parses into that type with serde; a shape mismatch is a real, handled error,
not a runtime surprise three components later.
Typed DOM helpers (frontend/src/dom.rs)
Section titled “Typed DOM helpers (frontend/src/dom.rs)”web-sys gives you the DOM, but every lookup returns an Option (the element
might not exist) and every “is this element actually an <input>?” cast returns
a Result via JsCast. These helpers wrap the ceremony.
//! DOM helpers built on `web-sys`. These are the typed Rust equivalents//! of `document.getElementById`, `el.textContent = ...`, and//! `el.addEventListener(...)` — the difference is that every call that//! could fail returns a `Result`/`Option` you must handle.
use wasm_bindgen::JsCast;use wasm_bindgen::prelude::Closure;use web_sys::{Document, Element, HtmlInputElement, HtmlTextAreaElement, window};
/// Grab `document`, panicking with a clear message if we're somehow not/// in a browser. In a real app you'd surface this more gracefully.pub fn document() -> Document { window() .expect("no global window (are we in a browser?)") .document() .expect("window has no document")}
/// `document.getElementById`, but returns a typed `Element`.pub fn get_by_id(doc: &Document, id: &str) -> Element { doc.get_element_by_id(id) .unwrap_or_else(|| panic!("missing #{id} in the DOM"))}
/// Read the trimmed value out of an `<input>` by id.pub fn input_value(doc: &Document, id: &str) -> String { get_by_id(doc, id) .dyn_into::<HtmlInputElement>() .expect("element is not an <input>") .value() .trim() .to_string()}
/// Read the trimmed value out of a `<textarea>` by id.pub fn textarea_value(doc: &Document, id: &str) -> String { get_by_id(doc, id) .dyn_into::<HtmlTextAreaElement>() .expect("element is not a <textarea>") .value() .trim() .to_string()}
/// Clear the value of an `<input>` by id (after a successful submit).pub fn clear_input(doc: &Document, id: &str) { if let Some(el) = doc.get_element_by_id(id) { if let Ok(input) = el.dyn_into::<HtmlInputElement>() { input.set_value(""); } }}
/// Clear the value of a `<textarea>` by id.pub fn clear_textarea(doc: &Document, id: &str) { if let Some(el) = doc.get_element_by_id(id) { if let Ok(area) = el.dyn_into::<HtmlTextAreaElement>() { area.set_value(""); } }}
/// Attach a click listener. We `forget()` the closure so it lives for the/// lifetime of the page — the wasm equivalent of not dropping a JS/// callback you still need. (For dynamic UIs you'd store and reuse these.)pub fn on_click<F: 'static + FnMut()>(element: &Element, mut handler: F) { let closure = Closure::<dyn FnMut()>::new(move || handler()); element .add_event_listener_with_callback("click", closure.as_ref().unchecked_ref()) .expect("failed to attach click listener"); closure.forget();}The interesting one is on_click. A Rust closure can’t be handed to the DOM
directly; Closure::new boxes it into something with a stable address that JS
can call. closure.forget() deliberately leaks it so it outlives the function;
otherwise Rust would drop it at the end of the scope and the listener would fire
into freed memory. This is the wasm version of the JavaScript rule “keep a
reference to a callback you still need.” For a long-lived SPA with churning
listeners you’d store the Closures instead of forgetting them, but for
page-lifetime handlers forget is the idiomatic shortcut.
The app entry point (frontend/src/lib.rs)
Section titled “The app entry point (frontend/src/lib.rs)”This ties it together. #[wasm_bindgen(start)] marks run() as the function the
runtime calls automatically on module load: no manual init from JavaScript. It
wires the Add note button and kicks off the first fetch. Because browser
futures can’t block, we drive them with spawn_local (the wasm analogue of
firing off an async function without awaiting it).
//! WASM frontend entry point.//!//! Compiles to a `.wasm` module that the browser loads. `run()` runs once//! on startup (like a top-level `<script type="module">`): it wires up the//! "Add note" button, then kicks off an async load of the note list.//!//! The whole thing is the Rust analogue of a tiny vanilla-JS SPA: fetch//! JSON, build DOM nodes, re-render on change.
mod api;mod dom;mod models;
use std::rc::Rc;
use wasm_bindgen::prelude::*;use wasm_bindgen_futures::spawn_local;use web_sys::Document;
use crate::models::{CreateNote, Note};
/// `#[wasm_bindgen(start)]` marks the function the runtime calls/// automatically once the module is instantiated — no manual init from JS.#[wasm_bindgen(start)]pub fn run() { // Route Rust panics to the browser console with a readable stack, // instead of an opaque "unreachable" trap. console_error_panic_hook::set_once();
let doc = dom::document();
// Wire up the "Add note" button. The closure captures `doc` (cheaply // cloneable — it's a handle) and spawns an async task on click. let add_button = dom::get_by_id(&doc, "add-btn"); let doc_for_click = doc.clone(); dom::on_click(&add_button, move || { let doc = doc_for_click.clone(); spawn_local(async move { submit_new_note(&doc).await; }); });
// Initial load: fetch existing notes and render them. spawn_local(async move { refresh_notes(&doc).await; });}
/// Read the form, POST a new note, clear the form, then re-render.async fn submit_new_note(doc: &Document) { let title = dom::input_value(doc, "title-input"); let body = dom::textarea_value(doc, "body-input");
if title.is_empty() { set_status(doc, "Title is required."); return; }
set_status(doc, "Saving..."); let payload = CreateNote { title, body };
match api::create_note(&payload).await { Ok(_) => { dom::clear_input(doc, "title-input"); dom::clear_textarea(doc, "body-input"); set_status(doc, ""); refresh_notes(doc).await; } Err(err) => set_status(doc, &format!("Failed to save: {err}")), }}
/// Fetch all notes from the API and rebuild the list in the DOM.async fn refresh_notes(doc: &Document) { match api::fetch_notes().await { Ok(notes) => render_notes(doc, ¬es), Err(err) => set_status(doc, &format!("Failed to load notes: {err}")), }}
/// Replace the contents of `#notes` with a freshly built list.fn render_notes(doc: &Document, notes: &[Note]) { let list = dom::get_by_id(doc, "notes"); list.set_inner_html(""); // clear previous render
if notes.is_empty() { let empty = doc .create_element("p") .expect("create <p> failed"); empty.set_text_content(Some("No notes yet. Add one above.")); list.append_child(&empty).expect("append failed"); return; }
for note in notes { list.append_child(¬e_element(doc, note)) .expect("append note failed"); }}
/// Build a single note `<li>` with a title, body, and delete button.fn note_element(doc: &Document, note: &Note) -> web_sys::Element { let item = doc.create_element("li").expect("create <li> failed"); item.set_class_name("note");
let title = doc.create_element("h3").expect("create <h3> failed"); title.set_text_content(Some(¬e.title)); item.append_child(&title).expect("append title failed");
if !note.body.is_empty() { let body = doc.create_element("p").expect("create <p> failed"); body.set_text_content(Some(¬e.body)); item.append_child(&body).expect("append body failed"); }
// Render the server-assigned timestamp via the browser's own // `Date` (js-sys), so we use the `created_at` field the API returns. let meta = doc.create_element("small").expect("create <small> failed"); let when = js_sys::Date::new(&JsValue::from_f64(note.created_at as f64)); let formatted: String = when .to_locale_string("en-US", &JsValue::UNDEFINED) .into(); meta.set_text_content(Some(&formatted)); item.append_child(&meta).expect("append meta failed");
let del = doc.create_element("button").expect("create button failed"); del.set_text_content(Some("Delete")); del.set_class_name("delete");
// `Rc` lets the click closure and the surrounding code share the same // document handle without fighting the borrow checker. let doc_rc = Rc::new(doc.clone()); let id = note.id; dom::on_click(&del, move || { let doc = doc_rc.clone(); spawn_local(async move { if api::delete_note(id).await.is_ok() { refresh_notes(&doc).await; } else { set_status(&doc, "Failed to delete note."); } }); }); item.append_child(&del).expect("append delete failed");
item}
/// Write a short message into the `#status` line.fn set_status(doc: &Document, message: &str) { if let Some(el) = doc.get_element_by_id("status") { el.set_text_content(Some(message)); }}The rendering is deliberately old-school: clear #notes, rebuild it from the
latest data. That’s the “re-render the world” model React popularized, done by
hand. Note the use of set_text_content (not set_inner_html) for
user-supplied strings. That’s the safe path that escapes content, the wasm
equivalent of React’s default text interpolation rather than
dangerouslySetInnerHTML.
Rc (reference-counted, single-threaded) appears in note_element because the
delete closure and the surrounding loop both need the Document handle. In the
browser everything runs on one thread, so Rc is the right tool; its
thread-safe cousin Arc would be overkill here. See
10 — Smart Pointers for the Rc vs Arc
distinction.
The page shell (static/index.html)
Section titled “The page shell (static/index.html)”Plain HTML with a sprinkle of CSS. The only dynamic part is the module script at
the bottom: it imports the wasm-pack-generated ES module and calls its default
export to initialize. wasm-pack’s --target web output is a self-contained
module: no bundler required.
<!DOCTYPE html><html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Notes — Rust Full-Stack</title> <style> :root { color-scheme: light dark; } body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; } h1 { margin-bottom: 0.25rem; } form { display: grid; gap: 0.5rem; margin: 1rem 0; } input, textarea, button { font: inherit; padding: 0.5rem; } ul { list-style: none; padding: 0; display: grid; gap: 0.75rem; } .note { border: 1px solid currentColor; border-radius: 0.5rem; padding: 0.75rem 1rem; } .note h3 { margin: 0 0 0.25rem; } .note p { margin: 0 0 0.5rem; } .delete { cursor: pointer; } #status { min-height: 1.25rem; color: crimson; } </style> </head> <body> <h1>Notes</h1> <p>Backend: Rust + axum. Frontend: Rust compiled to WebAssembly.</p>
<form id="note-form" onsubmit="return false"> <input id="title-input" type="text" placeholder="Title" /> <textarea id="body-input" rows="3" placeholder="Body (optional)"></textarea> <button id="add-btn" type="submit">Add note</button> </form>
<p id="status"></p> <ul id="notes"></ul>
<!-- wasm-pack (--target web) emits an ES module + the .wasm file in pkg/. We import it and call the default export to initialize. --> <script type="module"> import init from "./pkg/frontend.js"; init(); </script> </body></html>Running It
Section titled “Running It”The dev workflow, step by step
Section titled “The dev workflow, step by step”The workflow is: build the wasm bundle into static/pkg, then build and run
the backend, which serves both the bundle and the API. A build.sh is included
that does the first two steps:
#!/usr/bin/env bash# Build the WASM frontend into static/pkg, then build the backend.# Run from anywhere; paths are resolved relative to this script.set -euo pipefail
cd "$(dirname "$0")"
echo "==> Building WASM frontend (wasm-pack, --target web)"( cd frontend && wasm-pack build --target web --out-dir ../static/pkg --no-typescript )
echo "==> Building native backend"cargo build -p backend
echo "==> Done. Start the server with:"echo " cargo run -p backend"echo " # then open http://127.0.0.1:3000"1. Add the wasm target (once)
Section titled “1. Add the wasm target (once)”rustup target add wasm32-unknown-unknown2. Build the frontend to WebAssembly
Section titled “2. Build the frontend to WebAssembly”From the workspace root:
cd frontendwasm-pack build --target web --out-dir ../static/pkg --no-typescriptReal (tail of the) output from this directory:
Compiling frontend v0.1.0 (.../examples/full-stack-code/frontend) Finished `release` profile [optimized] target(s) in 32.35s[INFO]: found wasm-opt at "/opt/homebrew/bin/wasm-opt"[INFO]: Optimizing wasm binaries with `wasm-opt`...[INFO]: Done in 33.39s[INFO]: Your wasm pkg is ready to publish at .../full-stack-code/static/pkg.That produces static/pkg/frontend.js (the ES-module glue) and
static/pkg/frontend_bg.wasm (the actual module):
$ ls static/pkgfrontend.js frontend_bg.wasm package.json .gitignore$ du -h static/pkg/frontend_bg.wasm100K static/pkg/frontend_bg.wasmA 100 KB wasm module, size-optimized by the opt-level = "z" + lto profile in
the workspace root and then by wasm-opt.
If you only want to type-check the frontend without a full build (faster, and exactly what CI might run), use:
cargo check -p frontend --target wasm32-unknown-unknownwhich finishes with:
Checking frontend v0.1.0 (.../examples/full-stack-code/frontend) Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.53s3. Run the backend (which serves everything)
Section titled “3. Run the backend (which serves everything)”cargo run -p backendReal startup log:
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.03s Running `target/debug/backend`2026-06-02T07:17:59.348977Z INFO backend: backend listening on http://127.0.0.1:30002026-06-02T07:17:59.349043Z INFO backend: serving static files from .../full-stack-code/backend/../staticNow open http://127.0.0.1:3000 in a browser. The wasm module loads, calls
run(), fetches GET /api/notes, and renders the seeded “Welcome” note. Type a
title and body, click Add note, and the list updates; click Delete on a
card and it disappears.
Re-building after a frontend change: re-run the
wasm-pack buildcommand (or./build.sh). The backend servesstatic/pkgfresh on each request, so a browser refresh picks up the new bundle; no backend restart needed. Backend changes do need acargo runrestart (or usecargo watch -x 'run -p backend').
Exercising the API directly with curl
Section titled “Exercising the API directly with curl”With the server running, here is a real end-to-end session against the live API (responses are verbatim):
$ curl -s http://127.0.0.1:3000/api/notes[{"id":1,"title":"Welcome","body":"This note came from the Rust backend.","created_at":1780384166393}]
$ curl -s -X POST http://127.0.0.1:3000/api/notes \ -H 'Content-Type: application/json' \ -d '{"title":"Buy milk","body":"2 liters, oat"}'{"id":2,"title":"Buy milk","body":"2 liters, oat","created_at":1780384172457}
$ curl -s http://127.0.0.1:3000/api/notes[{"id":2,"title":"Buy milk","body":"2 liters, oat","created_at":1780384172457},{"id":1,"title":"Welcome","body":"This note came from the Rust backend.","created_at":1780384166393}]Validation and the error paths, with status codes:
$ curl -s -w "\n[HTTP %{http_code}]\n" -X POST http://127.0.0.1:3000/api/notes \ -H 'Content-Type: application/json' -d '{"title":" ","body":"x"}'{"error":"title must not be empty"}[HTTP 400]
$ curl -s -o /dev/null -w "[HTTP %{http_code}]\n" \ -X DELETE http://127.0.0.1:3000/api/notes/2[HTTP 204]
$ curl -s -w "\n[HTTP %{http_code}]\n" -X DELETE http://127.0.0.1:3000/api/notes/999{"error":"no note with id 999"}[HTTP 404]And confirming the same process serves the frontend bundle with the correct MIME types:
$ curl -s -o /dev/null -w "[HTTP %{http_code}] content-type=%{content_type}\n" \ http://127.0.0.1:3000/[HTTP 200] content-type=text/html
$ curl -s -o /dev/null \ -w "[HTTP %{http_code}] content-type=%{content_type} size=%{size_download}\n" \ http://127.0.0.1:3000/pkg/frontend_bg.wasm[HTTP 200] content-type=application/wasm size=99691With RUST_LOG="info,tower_http=debug" set, the TraceLayer logs each request.
Real output captured from a run:
DEBUG request{method=GET uri=/api/notes version=HTTP/1.1}: tower_http::trace::on_request: started processing requestDEBUG request{method=GET uri=/api/notes version=HTTP/1.1}: tower_http::trace::on_response: finished processing request latency=1 ms status=200DEBUG request{method=POST uri=/api/notes version=HTTP/1.1}: tower_http::trace::on_request: started processing requestDEBUG request{method=POST uri=/api/notes version=HTTP/1.1}: tower_http::trace::on_response: finished processing request latency=0 ms status=201Key Concepts
Section titled “Key Concepts”This project cements the ideas that make Rust full-stack work:
- One language, two targets. A Cargo workspace builds a native binary and a
wasm32module side by side, sharing dependency versions and a lockfile. The data shapes that cross the wire areserdestructs on both ends. See 12 — Modules & Packages for workspaces and 15 — Serialization forserde. - Explicit shared state.
Arc<Mutex<HashMap<..>>>is how you share mutable data across concurrent async tasks safely;AtomicU64gives lock-free id generation. The borrow checker makes the sharing visible instead of implicit. See 05 — Ownership and 10 — Smart Pointers. - Types as the API contract.
NoteisSerialize-only,CreateNoteisDeserialize-only, and axum’sJson<T>/Path<T>extractors validate input before your handler runs. Errors areResultvalues and HTTP status codes, not exceptions. See 08 — Error Handling and 16 — Web APIs. - Async on two runtimes. The backend runs on Tokio (
#[tokio::main],axum::serve); the browser useswasm-bindgen-futures::spawn_localto drive futures on the JS event loop. Both rely on the fact that Rust futures are lazy and need a runtime. See 11 — Async. - WASM ↔ DOM interop.
web-sysexposes typed DOM bindings gated by Cargo features;JsCast/dyn_intoperform checked casts;Closure+forgetbridge Rust closures into JS event listeners;gloo-netwrapsfetch. See 19 — WebAssembly.
Extending It
Section titled “Extending It”Concrete next steps if you want to push this further:
- Swap the in-memory store for a real database. Replace
AppState’sHashMapwith asqlx::SqlitePool(in-memorysqlite::memory:for tests, a file or Postgres in production) and make thelist/create/deletemethodsasync. The handler signatures barely move. Follow 17 — Database. - Add update + edit. Introduce
PUT /api/notes/{id}with anUpdateNotebody and an “Edit” button on each card. This exercises a fourth method and a secondDeserialize-only DTO. - Add authentication. Put a
towermiddleware layer in front of/apithat checks a bearer token or a session cookie, returning401on failure, the sameIntoResponsepattern as the400/404paths here. See 28 — Production for hardening. - Replace the hand-rolled DOM with a framework. Rewrite the frontend in
Leptos or Yew for reactive,
component-based rendering with signals instead of “clear and rebuild.” The API
client and
serdeshapes carry over unchanged.
Further Reading
Section titled “Further Reading”- 16 — Web APIs — axum routing, extractors, state, and middleware in depth.
- 17 — Database — connection pools and
sqlxfor a real persistence layer. - 19 — WebAssembly —
wasm-bindgen,web-sys, andwasm-packfundamentals. - 11 — Async — futures, Tokio, and
spawn_local. - 15 — Serialization —
serdein depth. - Other projects in this section: REST API, CLI Tool, WASM App, WebSocket Chat, Microservice.
- Official docs: axum, tower-http, wasm-bindgen guide, web-sys, gloo-net.