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