---
title: "Build your first page"
audience: "anyone who hasn't run FSR yet"
description: "You'll scaffold an app, change its loader to return some data and put that data on a page. A Rust binary serves it."
snippet_caption: "app/routes/page.loader.ts"
snippet: "import type { Ctx } from \"@snapfire/fsr/host\";\n\nexport async function load(_ctx: Ctx<\"/\">) {\n  return { greeting: \"Hello from Rust\" };\n}"
---

import { Install } from "@src/ui/Install";

You'll scaffold an app, change its loader to return some data and put that data on a page. A Rust binary serves it.

<Install captured="0.x" />

## Scaffold it

`--with react` vendors React as a plain file and fetches the type declarations your editor needs. Without it you get the same project with no framework in it.

```shell-session
$ fsr new hello --with react
wrote     hello/.gitignore
wrote     hello/config/app.toml
wrote     hello/config/development.toml
wrote     hello/config/production.toml
wrote     hello/app/importmap.json
wrote     hello/app/src/main.ts
wrote     hello/app/routes/layout.tsx
wrote     hello/app/routes/page.loader.ts
wrote     hello/app/routes/page.tsx
wrote     hello/app/routes/not-found.tsx
wrote     hello/app/routes/error.tsx
wrote     hello/app/styles/app.css
wrote     hello/app/generated/plan.sexp
wrote     hello/app/generated/contracts/schemas.json
wrote     hello/app/.fsr/bundle/routes/layout.tsx
wrote     hello/app/.fsr/bundle/routes/error.tsx
wrote     hello/app/.fsr/bundle/routes/not-found.tsx
wrote     hello/app/.fsr/bundle/routes/page.tsx
wrote     hello/app/generated/mdx.d.ts
wrote     hello/app/generated/shell.json
wrote     hello/app/generated/uploads.d.ts
wrote     hello/app/generated/assets.d.ts
wrote     hello/app/generated/assets.json
wrote     hello/app/generated/assets.map.json
wrote     hello/app/generated/native.d.ts
wrote     hello/app/generated/services.d.ts
wrote     hello/app/generated/elements.d.ts
wrote     hello/app/generated/head.ts
wrote     hello/app/generated/config.ts
wrote     hello/app/generated/config.schema.json
wrote     hello/app/generated/host.ts
wrote     hello/app/generated/islands.ts
wrote     hello/app/generated/client.ts
wrote     hello/app/generated/testing.ts
wrote     hello/app/tsconfig.json
wrote     hello/app/tsconfig.build.json
added     react                        react/react.bundle.mjs  10471 bytes
added     react/jsx-runtime            react/jsx-runtime.bundle.mjs  1493 bytes
added     react-dom/client             react-dom/client.bundle.mjs  215154 bytes
types     @snapfire/fsr/authoring      fsr 0.23.0
types     @snapfire/fsr/client         fsr 0.23.0
types     @snapfire/fsr/desktop        fsr 0.23.0
types     react                        @types/react 19.3.0
types     react-dom                    @types/react-dom 19.3.0
types     csstype                      csstype 3.2.3
recorded  typecheck.version = "7.0.2" in hello/config/app.toml
next      fsr dev hello/app
```

React lands as a single file under `app/vendor/` and the import map names it, which is how the browser finds it.

There are three config files. `config/app.toml` is always read. `config/development.toml` is read as well when `RELEASE_ENV` is `development` or unset. It turns on live refresh and the readable build. `config/production.toml` is read instead when `RELEASE_ENV` or `APP_ENV` is `production`: it serves the minified build, gives static files a cache lifetime and marks the session cookie secure. Its `[session] key` is a placeholder the host refuses to start on, so set `C5_SESSION__KEY` before you deploy.

If you forget the flag, `fsr use hello/app react` does the same thing afterwards. It also takes `vue`, `elements`, `htmx` and `tera`, which the later tutorials use.

Every import already resolves when you open the project, because of the `generated/` and `tsconfig.json` lines.

## Read the loader

A loader runs on the server and returns the data its page needs. Here is the one the scaffold wrote.

```ts title="app/routes/page.loader.ts"
import type { Ctx } from "@snapfire/fsr/host";

export async function load(_ctx: Ctx<"/">) {
  return { greeting: "Hello from Rust" };
}
```

This never ships to the browser. FSR reads it at build time and turns it into data the Rust host executes directly.

## Return something more

Add a list beside the greeting.

```ts title="app/routes/page.loader.ts" {4-7}
import type { Ctx } from "@snapfire/fsr/host";

export async function load(_ctx: Ctx<"/">) {
  return {
    greeting: "Hello from Rust",
    routes: ["/", "/about", "/posts/{id}"],
  };
}
```

## Put it on the page

The page takes the loader's return value as its props. The build writes the props type from the loader, so you don't write it yourself.

```tsx title="app/routes/page.tsx" {3,7-14}
import type { RootProps } from "@generated/client";

export default function Index({ greeting, routes }: RootProps) {
  return (
    <section className="hero">
      <h1>{greeting}</h1>
      <p>These came out of the loader, on the server.</p>
      <ul>
        {routes.map((route) => (
          <li key={route}>
            <code>{route}</code>
          </li>
        ))}
      </ul>
    </section>
  );
}
```

## Build it

```shell-session
$ fsr build hello/app --verbose
routes    /                      routes
layouts   /                      routes/layout.tsx#default
sources   $root                  lowered     routes/page.loader.ts
rendered  routes/layout.tsx#default          lowered     static
          routes/page.tsx#default            lowered     static
hoisted   routes/page.tsx#default            1 subtree
plan      generated/plan.sexp    1.9 KB written
typecheck tsc 7.0.2 from cache, clean
```

`lowered` means the loader and the page became data rather than JavaScript. `static` means the module has no state and no handlers, so the browser never runs it at all. `hoisted` means the parts of your markup that never change were folded into constants before the server answers a request.

Now look at what the build wrote for you:

```ts title="app/generated/client.ts"
export type RootProps = { greeting: string; routes: string[] };
```

Change the loader's return and that type changes with it. Destructure a field you didn't return and the build fails before you reach the browser.

## Run it

```shell-session
$ fsr dev hello/app --verbose
dev: watching hello/app and its configuration, served by the stock host; press Ctrl-C to stop
dev: server started, pid 33601
static    /static/js/app         hello/app/dist
          /static/js/vendor      hello/app/vendor
          /static/css            hello/app/styles
client    /static/js/fsr         17 modules, readable, 98 KiB from the binary
cache     1000 entries, ttl 1m
dev       live refresh on /__fsr/events, told by POST /__fsr/changed
fsr server on http://127.0.0.1:3000/
fsr traces on http://127.0.0.1:3000/__fsr/traces
```

Open it and you get your greeting and your three routes. Edit the page and the browser updates on its own.

The `client` row shows that the browser half of FSR comes out of the host binary, so it always matches the version you're running.

The last line is the trace collector, which a development server turns on for itself. [420](https://snapfirers.com/fsr/learn/420-debug-a-page-in-production) is about reading what it keeps.

Next up: [020. Build a counter](https://snapfirers.com/fsr/learn/020-build-a-counter), where the page gains a button and still ships no component code.
