Tutorial 1 of 54 · for anyone who hasn't run FSR yet · Markdown

Build your first page

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

Before you start

Install from crates.io. You need no Node and no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version

Every command and screenshot on this page was captured with fsr 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.

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

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.

app/routes/page.loader.ts
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.

app/routes/page.tsx
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#

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

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#

$ 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 is about reading what it keeps.

Next up: 020. Build a counter, where the page gains a button and still ships no component code.