Skip to content
forma.
v3.0
☰

Complete example / TypeScript + Rust

Small stockroom.
Real full-stack behavior.

Follow a quantity from a Rust-rendered page through an adopted editor, a validated request and a saved file. Then restart the server and read it back.

Download the complete project ↓Work through the lesson →

Local learning application. One process and a JSON file; no accounts or authorization. The server deliberately binds to loopback. Add the appropriate security and storage architecture before public deployment.

A small local inventory application built with published Forma 3.0.0. It extends the browser inventory example into a real Rust-backed application: native server rendering, typed request props, adopted editor islands, validated writes, stale-edit protection, and persistence across server restarts. Use it to follow one edit from an unsaved input to a server-confirmed value on disk.

Run the downloaded project#

Install Node.js 22+ and Rust 1.85+ first. This project has independent npm and Cargo lockfiles; it does not require the Forma source repository or the website.

From this folder:

sh
cd admin
npm ci
npm run build
cd ..
CARGO_BUILD_JOBS=3 cargo run --locked

npm ci uses the frontend lockfile. npm run build emits both browser assets and native FMIR; this project's build always enables SSR. cargo run --locked builds and starts Rust with its locked dependencies and the matching frontend output. The three-job limit controls compilation, not request handling.

Open http://127.0.0.1:4176. The first run creates data/inventory.json with three example items. The application deliberately listens only on loopback. The notebook starts at 8, pencils at 24 and map cases at 3. Stop it with Ctrl+C; run the last command again from this folder to load the same inventory.

To choose another local port or file:

sh
PORT=4186 DATA_FILE=/absolute/path/inventory.json CARGO_BUILD_JOBS=3 cargo run --locked

DATA_FILE is a server/operator setting, never a browser-supplied path. Do not edit the JSON file while the server is running: its current state is in memory. If the file is corrupt or has an incompatible schema, startup refuses instead of silently replacing it with the sample inventory.

Draft, confirmed value and saved data#

The input fields are a draft. Typing a quantity does not change the number labelled “confirmed in stock” or save anything. That number is the last value this editor received from Rust, not a live subscription to changes in other tabs.

Save changes sends the draft and its item version. With JavaScript enabled, the fields lock while saving and the existing editor updates only after Rust confirms the write. Reload inventory performs a new GET, replaces the draft with current stored data and returns to the same item. Copy an unsaved draft before using that link if you want to keep it.

The URL fragment identifies which editor to scroll to; it does not contain or save the quantity. Accepted quantities and notes live in data/inventory.json.

Try the complete request cycle#

  1. View page source. Names and stock values are already present in the response from Rust, together with declared island state.
  2. Change Field notebook to 17 and enter a shelf note. The confirmed count stays unchanged until you save. After acceptance it becomes 17 and the editor reports “Saved on the server.”
  3. Choose Reload inventory. The accepted quantity and note return in freshly rendered HTML, with the notebook editor still in view.
  4. Stop and restart the server, then open a fresh page. The same values return from the JSON file; an old tab alone would only show its existing snapshot.
  5. Open two tabs. Save in the first, then save the older second tab. Its version is stale, so the server returns 409 rather than overwriting newer data.
  6. Disable JavaScript and reload. The same forms POST to Rust. A successful write redirects to a fresh document at the edited item; validation failures return HTML with the error and submitted draft. Reload inventory fetches current data rather than resubmitting the POST.

Follow the code#

FileQuestion it answers
admin/src/app.tsxHow does a draft become a request, and when may the confirmed count change?
admin/build.mjsHow are native FMIR and the matching browser bundles produced?
src/main.rsHow are props bound, requests checked, and JSON or HTML responses selected?
src/store.rsWhen is a write accepted, and what happens if its version is stale or storage fails?

Rust fields use snake_case. PageProps derives camelCase wire names, matching TypeScript fields such as draftQuantity. The server computes presentation values such as form actions; the native compiler does not execute arbitrary TypeScript business logic in Rust.

The page uses createList(() => props.items, item => item.id, ...) with stable keys. Each editor initializes signals from its own declared item props and is registered through activateIslands({ StockEditor }). The native build removes the source whole-page mount; it does not mount a new app over server markup.

The submit handler captures the existing form, disables its two editable fields while the request is pending, sends ordinary URL-encoded form fields, and asks for a JSON response. It waits for confirmation before updating the displayed stock/version. Failed saves preserve the draft. After a network failure or eight-second timeout, the UI says it could not confirm the save and asks you to reload: the server may have committed before the response was lost. It does not blindly retry or claim the write failed.

A rejected no-JavaScript draft retains its submitted version, including after a 409 response. Repeatedly submitting that stale form therefore cannot overwrite the newer record. Use the Reload inventory link to fetch current data before editing again; this is an explicit GET rather than resubmitting a POST response. The link and ordinary-form redirects use the item's fragment to keep it in view without adding JavaScript to the fallback.

Every rendered page returns the complete Forma PageResponse, retaining its fresh nonce, CSP, and Cache-Control: no-store. Request data does not get concatenated into executable code or raw HTML.

Validation and storage boundaries#

Use an appropriate database and application authentication before adapting this example for multiple users. The downloadable project is not deployed by the Forma website and does not expose shared persistent demo data.

Tests#

The Rust tests are included in the download. Run them from this folder:

sh
CARGO_BUILD_JOBS=3 cargo test --locked

They cover input bounds, reopening saved data, stale and concurrent writes, write failure and corrupt-file refusal. Passing them checks the storage rules; it does not show whether a browser adopted the existing editor.

The website repository also runs a separate browser suite against this complete project. It uses disposable data files and OS-assigned ports, holds scripts to prove node identity during adoption, holds a save to verify confirmed-only updates, exercises disabled-JavaScript forms and conflicts, and actually stops and starts a new process before checking persisted values.

From the website root, after building this example:

sh
CARGO_BUILD_JOBS=3 cargo build --locked --manifest-path examples/field-notes/Cargo.toml
npx playwright test --config examples/course.playwright.config.mjs --workers=1

COURSE_APP_DIR can point that browser suite at a clean standalone copy. The template itself has no dependency on that repository-only browser harness.

When changing the UI, rebuild admin before compiling a release binary: its manifest, native modules and browser bundles must be embedded together.

Read every source file

The archive contains these same files, including npm and Cargo locks. It contains no installed dependencies, generated assets or runtime inventory.

Find your next answer.

Search Forma 3.0 documentation, guides and examples.