Skip to content

Project 6: Full-Stack App (Axum API + WASM frontend)

33 min read

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/cargo 1.96.0 toolchain, axum 0.8.9, tokio 1.52, tower-http 0.6, tracing 0.1, serde/serde_json 1.0, wasm-bindgen 0.2.122, wasm-bindgen-futures 0.4, js-sys/web-sys 0.3, gloo-net 0.7, and wasm-pack 0.13.1. The wasm32-unknown-unknown target is installed via rustup target add wasm32-unknown-unknown. Every command, log line, and HTTP response shown below was produced by actually building and running the code in full-stack-code/.

A two-crate workspace serving one app:

PieceCrateCompiles toRole
Backend APIbackendnative binaryGET/POST/DELETE JSON over HTTP; serves the static frontend too
Frontend SPAfrontendwasm32 .wasmfetches the API and renders/mutates the DOM in the browser

The JSON API has three endpoints:

Method & pathBodyResponseNode/Express analogue
GET /api/notes200 + 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:

Terminal window
$ 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}

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; JsCast in the browser.
  • 11 — Async: async/await, the Tokio runtime, and spawn_local for browser futures. Remember: Rust futures are lazy and need a runtime to drive them, unlike eager JavaScript Promises.
  • 15 — Serialization: the serde derives 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, and wasm-pack fundamentals.

You should also have the wasm target installed:

Terminal window
rustup target add wasm32-unknown-unknown

and wasm-pack (cargo install wasm-pack, or via the official installer).

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 a Cargo.lock, dependency versions, and one cargo invocation, exactly like a pnpm workspace shares one lockfile across packages.

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.

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.

full-stack-code/Cargo.toml
# 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 crates

resolver = "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 root package.json and 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.

full-stack-code/backend/src/models.rs
//! 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.

full-stack-code/backend/src/state.rs
//! 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)] on AppState is cheap: cloning an Arc just bumps a reference count, it does not copy the HashMap. Every request handler gets a clone of the handles, all pointing at the same store.
  • .lock() returns a Result because the mutex could be poisoned (a thread panicked while holding it). We .expect(..) here for brevity; production code would handle it. There’s no await while the guard is held, so we use the standard-library Mutex, 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 concurrent POSTs.

Swapping in a real database: replace AppState’s internals with a connection pool (e.g. sqlx::SqlitePool or PgPool) and make these methods async. 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.

full-stack-code/backend/src/handlers.rs
//! 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_note takes Json<CreateNote>, axum returns 422 Unprocessable Entity on its own if the JSON is malformed or missing fields — that branch never reaches your function. Our explicit 400 is additional business-rule validation (non-empty title).
  • axum 0.8 path syntax. The dynamic segment is {id}, not the :id used by older axum and by Express. Path<u64> even parses the string to a number for you; a non-numeric id yields a 400 automatically.

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.

full-stack-code/backend/src/router.rs
//! 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.)

full-stack-code/backend/src/main.rs
//! 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.)

full-stack-code/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.

Now the browser half. It’s a cdylib (a dynamic library the wasm toolchain post-processes), and it depends on the wasm-specific crates.

full-stack-code/frontend/Cargo.toml
[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.

full-stack-code/frontend/src/models.rs
//! 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 shared crate that both backend and frontend depend on, as long as it only uses #![no_std]-friendly, target-agnostic deps (serde qualifies). The mirror is shown here to keep the workspace to two crates and the moving parts visible.

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.

full-stack-code/frontend/src/api.rs
//! 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 equivalent
async 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.

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.

full-stack-code/frontend/src/dom.rs
//! 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.

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).

full-stack-code/frontend/src/lib.rs
//! 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, &notes),
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(&note_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(&note.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(&note.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.

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.

full-stack-code/static/index.html
<!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>

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:

full-stack-code/build.sh
#!/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"
Terminal window
rustup target add wasm32-unknown-unknown

From the workspace root:

Terminal window
cd frontend
wasm-pack build --target web --out-dir ../static/pkg --no-typescript

Real (tail of the) output from this directory:

Compiling frontend v0.1.0 (/Users/ahmet/Code/ts_to_rust/30-projects/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):

Terminal window
$ ls static/pkg
frontend.js frontend_bg.wasm package.json .gitignore
$ du -h static/pkg/frontend_bg.wasm
100K static/pkg/frontend_bg.wasm

A 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:

Terminal window
cargo check -p frontend --target wasm32-unknown-unknown

which finishes with:

Checking frontend v0.1.0 (.../full-stack-code/frontend)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.53s

3. Run the backend (which serves everything)

Section titled “3. Run the backend (which serves everything)”
Terminal window
cargo run -p backend

Real 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:3000
2026-06-02T07:17:59.349043Z INFO backend: serving static files from .../full-stack-code/backend/../static

Now 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 build command (or ./build.sh). The backend serves static/pkg fresh on each request, so a browser refresh picks up the new bundle — no backend restart needed. Backend changes do need a cargo run restart (or use cargo watch -x 'run -p backend').

With the server running, here is a real end-to-end session against the live API (responses are verbatim):

Terminal window
$ 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:

Terminal window
$ 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:

Terminal window
$ 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=99691

With 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 request
DEBUG request{method=GET uri=/api/notes version=HTTP/1.1}: tower_http::trace::on_response: finished processing request latency=1 ms status=200
DEBUG request{method=POST uri=/api/notes version=HTTP/1.1}: tower_http::trace::on_request: started processing request
DEBUG request{method=POST uri=/api/notes version=HTTP/1.1}: tower_http::trace::on_response: finished processing request latency=0 ms status=201

This project cements the ideas that make Rust full-stack work:

  • One language, two targets. A Cargo workspace builds a native binary and a wasm32 module side by side, sharing dependency versions and a lockfile. The data shapes that cross the wire are serde structs on both ends. See 12 — Modules & Packages for workspaces and 15 — Serialization for serde.
  • Explicit shared state. Arc<Mutex<HashMap<..>>> is how you share mutable data across concurrent async tasks safely; AtomicU64 gives 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. Note is Serialize-only, CreateNote is Deserialize-only, and axum’s Json<T>/Path<T> extractors validate input before your handler runs. Errors are Result values 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 uses wasm-bindgen-futures::spawn_local to 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-sys exposes typed DOM bindings gated by Cargo features; JsCast/dyn_into perform checked casts; Closure + forget bridge Rust closures into JS event listeners; gloo-net wraps fetch. See 19 — WebAssembly.

Concrete next steps if you want to push this further:

  1. Swap the in-memory store for a real database. Replace AppState’s HashMap with a sqlx::SqlitePool (in-memory sqlite::memory: for tests, a file or Postgres in production) and make the list/create/delete methods async. The handler signatures barely move. Follow 17 — Database.
  2. Add update + edit. Introduce PUT /api/notes/{id} with an UpdateNote body and an “Edit” button on each card. This exercises a fourth method and a second Deserialize-only DTO.
  3. Add authentication. Put a tower middleware layer in front of /api that checks a bearer token or a session cookie, returning 401 on failure — the same IntoResponse pattern as the 400/404 paths here. See 28 — Production for hardening.
  4. 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 serde shapes carry over unchanged.