Basics · for app developers

Data and rendering

Three places a page can be rendered

Prerendered. A route whose loader reads nothing request-specific (no params, no query, no session, no identity, no clock) can be rendered once at build time and answered from a file. fsr prerender <app> writes them, [server] prerender names the directory and the response carries x-sf-prerendered: 1. The boot report lists which routes qualified.

Server-rendered. The normal case. The host evaluates the lowered loader, walks the plan and writes HTML. This is Rust interpreting data, not a JavaScript VM starting.

Hydrated. Anything the browser has to keep interactive. Only islands are shipped as code.

The interesting thing is how little falls into the third bucket. A page is server-rendered whenever the build could lower it, and lowering is the default.

BrowserGET /cartRust hostroute matchplan.jsonthe route treeloader IRinterpretedHTMLno JS engine

What the browser actually receives

HTML, one stylesheet link per file in app/styles/, one inline import map and one entry module. The entry registers the islands, boots and turns links into payload navigations. Everything else is markup.

A navigation is not a page load. The client asks the same route for a payload rather than a document, and fills the changed part of the tree in place. The layout's DOM, its islands and their state all survive. Text typed into a header search box is still there after the page under it changes.

Handing the whole render to the browser

The three above are decided per component by what the build could lower. One setting moves the whole application to one end of that range:

toml
[server]
render = "islands"

With it no evaluator is registered, so every lowered component falls to the null evaluator and becomes a node naming a module with its props. Nothing about the build changes: components still lower and the report still says so. What changes is that nothing asks the interpreter to render one.

The server still does most of the work. Loaders and actions run in Rust, because they never went through an evaluator, and so do metadata, the store seed, the session and routing. The document carries the shell, the head, the store seed, the loader data as each island's props and one region per plan child. The browser mounts each module rather than hydrating it, since there is no server markup to hydrate against.

What you gain is that no component has to render twice and agree, so residue stops mattering because everything is residue. What you lose is the first paint: the document has no page content until the bundle has loaded and run. That is the right trade behind a login where nothing is indexed and the first paint is a spinner either way, and it is the honest way to run a component library that will never lower.

What a build produces

app/routes, app/srcTypeScript you writeplan.jsonroutes, loader IR, component treesdist/islands, client runtime, vendorread by the Rust hostnever sent to the browserloaded by the browsernever executed on the serverlowerbundle

The split is the whole design. The plan is data, so it can be diffed, hashed, validated and swapped into a running process. The bundle is code, and it never runs on the server.

The store

A loader's store export seeds keys the browser can read without another request.

ts
export const store = ({ data }: { data: { cart: bigint } }) => ({
  "shop/cart_count": Number(data.cart),
});

An island reads it with a typed key:

ts
import { key } from "@snapfire/fsr-client/store";
export const cartCount = key<number>("shop/cart_count");

The store is one global map keyed by string. Nothing namespaces the keys for you, so prefix them yourself. Two features using "count" will find each other's value.

Errors and misses

An error.tsx receives { error: string } when the loader fails: a service that is down, a response the contract rejected or a fail the body raised. The document renders around it, so an error is a page with a message rather than a blank tab.

A routes/not-found.tsx answers a path no route matches, with status 404 and params.path carrying what was asked for.

Note the seam between those two. A path that matches a route pattern but finds nothing (/product/does-not-exist) goes through the loader, so fail("not_found", …) renders the error boundary rather than the not-found page, and the response carries 200. Only an unmatched path produces a 404 status.

The lab

Run fsr prerender <app> and read the boot report's prerender list. Then make one of those loaders read query and build again: it drops off the list, because it can no longer be answered from a file.

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