Start
Build a full app
Add pages from files, data from a server, forms, sign-in, an API, tests and a deployment.This page as MarkdownStatuscurrent on 2026-11-06, for version 0.
Related documentsgetting started, SurfWave reference, limits of version 0, security
Read getting started first. This guide adds what a real web app needs: pages from files, data from a server, forms, sign-in, an API, tests and a deployment. examples/notes holds all the code of this guide, and the CI builds it.Make this decision first, because version 0 limits it.surf routes examples/notes lists the routes. surf dev serves them. A project with server code also needs its server, so surf dev builds the server binary in the background and sends it the data requests, the forms and the endpoints. The first build is a native build, and it takes some minutes. A page shows its data when the output of surf dev says the server code runs.A layout shows its page with @slot default:A load.server.surfs file runs on the server only. layout gives the data of Layout.surf, and load gives the data of Page.surf:The implementation is store.server.native.rs:A String parameter is &String in Rust. surf extern stubs <folder> writes a stub with the correct types. Json.encode and Json.decode turn records into text. Write a file with one writer: the server handles requests on more than one thread.A relative path such as notes.json is in the working folder of the server. The server must be able to write into that folder. The unit of --adapter systemd makes /var/lib/<name> the working folder, and the other folders of the system are read-only for the app.A form is a Column or another container with the role form. Its action names a function of actions.surfs after ?/. An Input needs a name. The form works with no script. With the script, the page stays and shows the result:The action reads request.form:The result reaches the page in the prop form, as JSON text. Json.decode gives the Failure back. A redirect is the usual end of an action that worked.A form from another site gets status 403. The server checks the origin of each form. An action must still check that the user can do the action.Wave.setCookie(name, value, maxAge) works in a load of the server, an action, an endpoint, a hook and a remote function. Each cookie has HttpOnly, SameSite=Lax and Path=/. Each cookie also has Secure, except on localhost, 127.0.0.1 and [::1].examples/notes trusts the name, so that the guide stays short. A real app checks a password in its API, and it stores a random session id in the cookie, not a name. The hook must then ask the API about that id.A hook protects pages. before runs for each request, so the check also holds for a request with no script and for the data of the page:For code that only your own pages call, a remote function needs less: see the SurfWave reference, section 6.Use these four steps.The binary has no TLS, so a public site needs a proxy in front of it. Set ORIGIN for that site: a proxy that changes Host makes the server refuse each form. Security has the steps, and the settings of each limit.A binary runs on the system that built it, so build on Linux for a Linux host. The Dockerfile of --adapter docker copies that binary into an image:An app with no server code can use --target static, or the adapters netlify, vercel, cloudflare and github-pages. The SurfWave reference, section 5, has the list. A serverless host cannot run a Surf server, because the server is a binary with a port.
- A project has no package manager. An extern Rust file can use the Rust standard library and the Surf runtime, but no other crate. So a Surf server has no client for a database.
- For a real database, run an API beside the Surf server. Set API_ORIGIN to its address. A load function calls it with Http.get("/api/x"), and the server forwards the cookies and the authorization header. Only a relative URL gets these headers: an absolute URL goes out without them. A request from the browser to /api/ goes to the same address, so the API must check each request. examples/tickets works this way.
- For a small app, a file is enough. This guide uses a file, so that the guide has one process.
text
src/ model.surfs the types store.server.surfs the notes (server only) store.server.native.rs the file of the notes hooks.server.surfs the check for each request routes/ Layout.surf the frame of each page Page.surf / Error.surf the page for a failed load load.server.surfs the data of Layout.surf and Page.surf notes/[id]/Page.surf /notes/1 notes/[id]/load.server.surfs new/Page.surf /new new/actions.surfs the form of /new login/Page.surf /login login/actions.surfs the form of /login api/notes/server.surfs GET /api/notes
surfRun
prop user: String;slot default;---<Column style="p-6 gap-4 max-w-160"> <Row style="gap-3 items-center"> <Link href="/">Notes</Link> <Link href="/new">New note</Link> </Row> @slot default</Column>
surf
import { Home, Session } from "~/model.surfs";import { allNotes } from "~/store.server.surfs";import { Wave } from "surf:wave";pub async fn layout() -> Result<Session, String> { return .ok({ user: Wave.cookie("user") });}pub async fn load() -> Result<Home, String> { return .ok({ notes: allNotes() });}
- ~/ is a path from src.
- The result types must be in a file that the browser can read, such as model.surfs. A type in a server file is a compile error, because the browser reads the data too.
- A page declares a field of the data as a prop: prop notes: List<Note>.
- .err(notFound()) shows Error.surf with status 404. .err(failure(403, "No")) gives another status, and .err(redirect("/login")) goes to another page.
- The browser reads the value of each load function, also on the server. Return only what the page shows.
- Wave.env in a file that the browser also reads takes only a name in quotes that starts with PUBLIC_. Read a secret in a *.server.surfs file.
- Check a route parameter before you put it into a URL. / and ? in a value change the address. Use a matcher: [id=int] or [id=uuid] as a folder name.
surf
pub extern module Disk { fn read() -> String; fn write(text: String);}
rust
pub fn read() -> String { std::fs::read_to_string("notes.json").unwrap_or_default()}pub fn write(text: &String) { let _ = std::fs::write("notes.json", text);}
surf
<Column role="form" method="post" action="/new?/save" style="gap-2"> <Input name="title" alt="Title" value="" /> <Button>Save</Button> <Text>{problem(form)}</Text></Column>
surf
import { addNote } from "~/store.server.surfs";import { Failure, Request, failure, redirect } from "surf:wave";pub async fn save(request: Request) -> Result<String, Failure> { const title = (request.form["title"] ?? "").trim(); if title.isEmpty() { return .err(failure(422, "A note needs a title")); } const note = addNote(title, request.cookies["user"] ?? ""); return .err(redirect("/notes/{note.id}"));}
surf
import { Failure, Request, Wave, redirect } from "surf:wave";pub async fn signIn(request: Request) -> Result<String, Failure> { Wave.setCookie("user", request.form["name"] ?? "guest", 86400); return .err(redirect("/"));}
surf
import { Request, Response, redirectResponse } from "surf:wave";pub async fn before(request: Request) -> Response? { if request.path == "/new" && !request.cookies.contains("user") { return redirectResponse("/login"); } return none;}
- request.path has one form. The server sends //new, /%6eew and /new/ to /new before the hook runs, so == is safe.
- For a folder, compare the folder and the text under it: request.path == "/admin" || request.path.startsWith("/admin/"). Do not use startsWith("/admin") alone, because /administration matches it too. The server refuses %2F and %5C in a path, so /admin%2Fusers cannot pass this guard and reach a route with [...rest].
- A remote function has the path /__remote/<file>/<name>, and an endpoint has its own path. Guard these paths too.
- A redirect to a value of a user can leave your site. Accept only a path: security, section 4, has a function for this.
surf
import { allNotes } from "~/store.server.surfs";import { Request, Response, jsonResponse } from "surf:wave";pub async fn get(request: Request) -> Response { return jsonResponse(Json.encode(allNotes()));}
Function
What it does
Test.expect(condition, message)
Fails the test if the condition is false.
Test.equal(found, expected)
Fails the test if the two strings differ.
Test.fail(message)
Fails the test.
Test.mount(file, props: json)
Runs the component of the file with no window. props is optional.
Test.press(id)
Sends a press to the center of the element with that testid.
Test.pressText(text)
Sends a press to the button with that text or that alt. One button must have it.
Test.hover(id)
Moves the pointer to the center of the element with that testid. The pointer stays there.
Test.fill(id, text)
Writes the text into the field with that testid.
Test.key(key)
Sends a key to the element with the focus, for example "Tab", "Enter" or "a".
Test.text(id), Test.value(id)
Reads the text of an element, or the value of a field.
Test.count(id)
Counts the elements with that testid.
Test.buttons(text)
Counts the buttons with that text or that alt.
Test.focused()
Reads the alt or the text of the element with the keyboard focus. With no focus, it gives "".
Test.color(id)
Reads the text color of the element with that testid, as #rrggbb.
Test.role(text), Test.description(text)
Reads the role, or the description, that a screen reader gets for the button with that text.
Test.access()
Reads the accessibility tree: one line for each node, with its role and its name.
Test.wait(ms)
Moves the clock of the test by ms milliseconds. No real time passes. Timers, tasks and animations see the new time.
Test.settle()
Waits in real time for tasks that wait for another thread, for example a request. It stops when the text does not change for 25 frames, or after 2 seconds.
Test.resize(width, height)
Gives the window another size in logical px, for example for a narrow layout. The default is 800 × 600.
- surf check <folder> checks the types of the whole project. Run it first.
- surf lint <folder> finds code that compiles but is probably wrong. A finding gives a non-zero exit code, so a CI job can fail on it. A comment lint-ignore <rule> on the line of a finding silences it. The rules are name-case, for-key, unused, no-effect, no-name (ADR-0070), contrast (ADR-0103), duplicate-id (ADR-0106), and media-format, no-captions and autoplay-stop (ADR-0155 to ADR-0158).
- surf test <folder> runs each pub fn test... of the .surf files in tests/ (ADR-0069). --filter <word> runs only the tests with that word in the name. This is examples/notes/tests/pages.surfs:
- surfimport { Test } from "surf:test";pub fn testTheFormOfANewNoteStartsWithNoProblem() { Test.mount("routes/new/Page.surf"); Test.equal(Test.text("form"), ""); Test.expect(Test.buttons("Save") == 1, "the form has one button Save");}
- A test runs no server. It reads the files that the browser reads, so it cannot import a server file. Test.mount mounts one component, for example one page of a route. A prop with no default value gets its value from props, a JSON object: Test.mount("Row.surf", props: "\{\"label\": \"Milk\"\}"). The JSON of a value is the JSON of Json.encode, so Json.encode of a record of the props also gives it. Test the server code with curl, as the end of this section shows.
- The functions of surf:test:
- Use Test.wait for a time in the app, for example a toast that closes after 10 seconds. Use Test.settle for a request: Test.wait gives the request no real time.
- A test runs the native layout, not the CSS of the web. An extern module needs a host function or a fallback block in a test.
- Test the build in a browser with Playwright. On the web, a testid is the attribute data-testid:
- javascripttest("a new note shows", async ({ page }) => { await page.context().addCookies([{ name: "user", value: "ada", url: "http://localhost:3000" }]); await page.goto("http://localhost:3000/new"); await page.locator("[data-testid=title]").fill("Milk"); await page.locator("[data-testid=save]").click(); await expect(page.locator("[data-testid=title]")).toHaveText("Milk");});
Variable
Meaning
HOST
The address. The default is 127.0.0.1, so a proxy on the same machine can reach the server and other machines cannot. A container needs 0.0.0.0, and the Dockerfile of --adapter docker sets it.
PORT
The port.
ORIGIN
The address of the site in the browser, for example https://example.com.
API_ORIGIN
The address of your API.
Your own names
Wave.env("NAME") reads them in a file of the server.
bash
surf build examples/notes --adapter docker
bash
surf build examples/notes --adapter systemd
Need
Where
Colors, fonts and dark mode
src/style.toml: layout spec, section 2.1
Images and other files
src/assets/: component reference, section 4.5
Icons
Component reference, section 4.12
Long lists
List: component reference, section 4.3
Dialogs and menus
Overlay and Select: component reference
Screen readers
alt, role and the other attributes: component reference, section 1
Animation
Transition and transition- utilities: layout spec, section 12
Animations that follow the scroll, and the split of a text
Layout spec, section 5.10
Animations in an order
sequence: language spec, section 7.5a
Page transitions
Wave.animateRoutes(true): SurfWave reference, section 7
Gradients, shadows, blurs and blend modes
Layout spec, section 5.8
Video and sound
Video and Audio: component reference, sections 4.5b to 4.5d
Numbers, dates and lists in the language of the user
Locale: standard library, section 6a
Methods that several types share
Traits, bounds and dyn: language spec, sections 7.8 and 7.9
Code in Rust or JavaScript
Extern modules: language spec, section 13
A desktop app
What the server does for safety, and what your app must do
What does not exist yet