# Build a full app

- **Status:** current on 2026-11-06, for version 0.
- **Related documents:** [getting started](https://surface.solenovis.com/docs/getting-started.md), [SurfWave reference](https://surface.solenovis.com/docs/routes.md), [limits of version 0](https://surface.solenovis.com/docs/limits.md), [security](https://surface.solenovis.com/docs/security.md)

Read [getting started](https://surface.solenovis.com/docs/getting-started.md) 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.

---

## 1. Decide where your data lives

Make this decision first, because version 0 limits it.

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

---

## 2. Make the routes

A project with a `src/routes` folder has no `src/App.surf`. Delete that file. Each folder is a part of a URL:

```
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
```

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

```surf
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>
```

---

## 3. Load data on the server

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

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

---

## 4. Store data in an extern module

An extern module has a declaration, and an implementation for each target. The name of `store.server.surfs` keeps it out of the browser:

```surf
pub extern module Disk {
    fn read() -> String;
    fn write(text: String);
}
```

The implementation is `store.server.native.rs`:

```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);
}
```

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.

---

## 5. Take a form

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:

```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>
```

The action reads `request.form`:

```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}"));
}
```

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.

---

## 6. Sign in

`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]`.

```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("/"));
}
```

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

```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](https://surface.solenovis.com/docs/security.md), section 4, has a function for this.

A `layout` function can also guard a folder. `.err(redirect("/login"))` in that function stops each page in the folder, and the data of each page. [Security](https://surface.solenovis.com/docs/security.md) lists what the server does, and what an app must do itself.

---

## 7. Answer with JSON

A `server.surfs` file has a function for each method:

```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()));
}
```

For code that only your own pages call, a remote function needs less: see the [SurfWave reference](https://surface.solenovis.com/docs/routes.md), section 6.

---

## 8. Test the app

Use these four steps.

1. `surf check <folder>` checks the types of the whole project. Run it first.
2. `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).
3. `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`:

   ```surf
   import { 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`:

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

   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.

4. Test the build in a browser with Playwright. On the web, a `testid` is the attribute `data-testid`:

   ```javascript
   test("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");
   });
   ```

Test a server with `curl`: `tests/server.sh` of this repository is a complete example.

---

## 9. Deploy

`surf build --target server` writes `build/server/<name>-server`: one binary and no other file. A server is a native build, so the first one downloads a private Rust toolchain and the runtime pack into `~/.surf` ([the `surf` command](https://surface.solenovis.com/docs/commands.md)). Copy the binary to the host. It reads these environment variables:

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

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](https://surface.solenovis.com/docs/security.md) 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:
```bash
surf build examples/notes --adapter docker
```

```bash
surf build examples/notes --adapter systemd
```

An app with no server code can use `--target static`, or the adapters `netlify`, `vercel`, `cloudflare` and `github-pages`. The [SurfWave reference](https://surface.solenovis.com/docs/routes.md), section 5, has the list. A serverless host cannot run a Surf server, because the server is a binary with a port.

---

## 10. What else an app needs

| Need | Where |
|---|---|
| Colors, fonts and dark mode | `src/style.toml`: [layout spec](https://surface.solenovis.com/docs/layout.md), section 2.1 |
| Images and other files | `src/assets/`: [component reference](https://surface.solenovis.com/docs/components.md), section 4.5 |
| Icons | [Component reference](https://surface.solenovis.com/docs/components.md), section 4.12 |
| Long lists | `List`: [component reference](https://surface.solenovis.com/docs/components.md), section 4.3 |
| Dialogs and menus | `Overlay` and `Select`: [component reference](https://surface.solenovis.com/docs/components.md) |
| Screen readers | `alt`, `role` and the other attributes: [component reference](https://surface.solenovis.com/docs/components.md), section 1 |
| Animation | `Transition` and `transition-` utilities: [layout spec](https://surface.solenovis.com/docs/layout.md), section 12 |
| Animations that follow the scroll, and the split of a text | [Layout spec](https://surface.solenovis.com/docs/layout.md), section 5.10 |
| Animations in an order | `sequence`: [language spec](https://surface.solenovis.com/docs/language.md), section 7.5a |
| Page transitions | `Wave.animateRoutes(true)`: [SurfWave reference](https://surface.solenovis.com/docs/routes.md), section 7 |
| Gradients, shadows, blurs and blend modes | [Layout spec](https://surface.solenovis.com/docs/layout.md), section 5.8 |
| Video and sound | `Video` and `Audio`: [component reference](https://surface.solenovis.com/docs/components.md), sections 4.5b to 4.5d |
| Numbers, dates and lists in the language of the user | `Locale`: [standard library](https://surface.solenovis.com/docs/standard-library.md), section 6a |
| Methods that several types share | Traits, bounds and `dyn`: [language spec](https://surface.solenovis.com/docs/language.md), sections 7.8 and 7.9 |
| Code in Rust or JavaScript | Extern modules: [language spec](https://surface.solenovis.com/docs/language.md), section 13 |
| A desktop app | `surf build --target desktop`. An extern event reaches `surf dev` too. `[desktop] systems` of `surf.toml` lists the systems, also Windows, and `surf package` makes the installers: [the `surf` command](https://surface.solenovis.com/docs/commands.md), section 3 |
| What the server does for safety, and what your app must do | [Security](https://surface.solenovis.com/docs/security.md) |
| What does not exist yet | [Limits of version 0](https://surface.solenovis.com/docs/limits.md) |
