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

Build your first page

You'll scaffold an app, change its loader to return some data and put that data on a page. What serves it's a Rust binary with no JavaScript engine in it.

Before you start

Straight from crates.io. No Node, 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

One command. --with react is what vendors React as a plain file and fetches the type declarations your editor needs; leave it off and you get the same project with no framework in it at all.

$ fsr new hello --with react
wrote     hello/.gitignore
wrote     hello/config/app.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/tsconfig.json
added     react                        react/react.bundle.mjs  8715 bytes
added     react/jsx-runtime            react/jsx-runtime.bundle.mjs  2192 bytes
added     react-dom/client             react-dom/client.bundle.mjs  136190 bytes
types     @snapfire/fsr-authoring      fsr 0.13.0
types     @snapfire/fsr-client         fsr 0.13.0
types     react                        @types/react 18.3.31
types     react-dom                    @types/react-dom 18.3.7
next      fsr dev hello/app

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

If you forget the flag you're not stuck: fsr use hello/app react does the same thing afterwards. It also takes vue, elements, htmx and tera, which is what the later tutorials reach for.

Open the project now and every import already resolves. That's what the generated/ and tsconfig.json lines are doing.

Read the loader

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

ts
import type { Ctx } from "@snapfire/fsr";

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
import type { Ctx } from "@snapfire/fsr";

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. You don't write that type, the loader does.

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

Three words matter there. 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
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
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, 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.

That client row is worth a look. The browser half of FSR comes out of the host binary, so there's no copy of it in your project to fall behind 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.

Recap

Your page rendered in Rust. The loader ran in Rust. Nothing in the serving path evaluated JavaScript. The browser downloaded the markup plus React for the parts that need it.

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

Built with SnapFire FSR. Pure Rust runtime, zero Node.js on the server.

Proudly Created by Excerion Sun LLC