SnapFire FSR

TypeScript apps, run by a Rust server

FSR compiles routes, loaders, actions and pages into a plan at build time. The Rust server runs that plan for every request. Each feature below links to the tutorial that builds it.

Build and request

How a request is served

01 · buildWrite

Routes, loaders, actions and pages in TypeScript. Pages can be Tera templates instead.

02 · buildCompile

Loaders become a plan. Islands become ES modules through snapfirec.

03 · requestRun

The host matches the route, runs the loaders and renders the page.

04 · requestRespond

Islands hydrate when they become visible. Server islands send no component code.

build time, oncerequest time, every request

What FSR does

render

Pages rendered in Rust

Loaders and pages compile to a plan that the Rust host runs. There is no JavaScript engine on the server, so the first request after a restart is as fast as later ones.

Tutorial
compiled from page.loader.ts
(let count
  (reduce (values (session cart)) 0
    (fn (n q) (+ n q))))
render

Server islands

The browser downloads no component code for a server island. A click sends the event to the server, which runs the handler and sends back the changed markup.

Tutorial
a server island, live
Served by snapfire_fsr_host
render

Streaming

The page is sent straight away and a slow section is filled in when its data arrives. You don't write a promise or a suspense boundary.

Tutorial
routes/product/[id]/page.loader.ts
import type { Ctx } from "@snapfire/fsr";

export async function load({ params, services }: Ctx<"/product/{id}"> ) {
  const product = await services.shopping.getProduct({ id: params.id });
  const suggestions = await services.recommend.forProduct({ id: params.id });
  return { product, suggestions };
}
data

Session state

The cart is stored in the session on the server. Adding an item is one typed call and there is no client store to keep in sync.

Tutorial
app/schemas/session.ts
export interface Session {
  cart: Record<string, bigint>;
}

export const defaults: Session = {
  cart: {},
};
data

Typed service calls

The API client and its request and response types are generated from an OpenAPI document. A loader calls the service like a function.

Tutorial
a loader
const product = await services.shopping.getProduct({ id });
const reviews = await services.reviews.forProduct({ id });
build

Third-party libraries

Add Chart.js without npm or node_modules. The library is copied into the app and loaded through the import map.

Tutorial
fsr add
$ fsr add shop/app chart.js@4.4.7
added     chart.js                     chart.js/chart.bundle.mjs  202502 bytes
ship

Deployment

fsr bundle writes a directory that you copy to the server with the binary. fsr doctor runs checks for failures that only show up in production.

Tutorial
fsr doctor
$ fsr doctor shop/app
doctor       13 checks, nothing to report
ship

Request traces

Each request is recorded as a tree of spans. It shows which loader waited on which service, what ran in parallel and which cache answered.

Tutorial
fsr dev
$ fsr dev shop/app
fsr server on http://127.0.0.1:3000/
fsr traces on http://127.0.0.1:3000/__fsr/traces

More features, each with a tutorial

Vue islandsWrite islands as Vue single-file components. The server renders them and the browser hydrates them. React is left out of the import map, the bundle and the types.→React and Vue on one pageA React island and a Vue island can share a page and a piece of state. The layout stays in place when you navigate between pages.→Islands as custom elementsWrite the interactive parts as custom elements. The browser upgrades them over the markup the server rendered.→Tera pagesWrite a page as a Tera template and the host renders it from the file. Routing, loaders and prerendering work the same way.→PrerenderingPosts are written to disk as files at build time. A post that wasn't prerendered is rendered when it is requested.→Page and loader cachingA route that doesn't read the request is rendered once and served from a file. On other routes you can cache the slow part of the page.→Light and dark themesThe server sends the page in the visitor's theme, so it doesn't flash on load. No script runs in the head and nothing is read from localStorage first.→Form validationWhen input is invalid, the form comes back with the message next to the field and the visitor's input kept. This works with or without JavaScript.→File uploadsAccept a file from a form, check it and pass the bytes to a service. You don't need a multipart parser or a temp directory.→MiddlewareA middleware file runs before every route. The tutorial uses one to redirect an anonymous visitor to sign in.→Multiple domainsOne binary serves two host names. A loader reads which one was requested and a forged Host header can't change it.→Build errorsCode that can't be compiled to the plan fails the build. The error gives the line, the reason and what to use instead.→Loader testsTests run your loader in the same interpreter that serves requests. You don't install Node or a test framework.→Porting from Next.jsPort the data fetch, the server action and the client component of a Next.js product page to FSR.→Loaders in RustReplace one loader with a Rust function. The page, the route and the tests stay the same.→Embedding in a Rust serviceThe host is a tower::Service, so you can mount it inside an existing binary with your own runtime and listener.→Tera without TypeScriptBuild a notes board from Tera templates only. It has no app directory, .tsx files or plan file. You don't run the fsr CLI or snapfirec.→Mounted sitesMount a second FSR application at a URL prefix. The two apps can live in separate repositories and be released separately.→Content-Security-PolicyA new application starts on a report-only policy. fsr dev lists each violation with the change that would admit it.→Checking a deploy for missing filesCheck that the deploy tree serves every file the page requests, including requests that fail without changing what's on screen.→Analytics with consentLoad Google Analytics only after the visitor accepts the analytics category. The measurement ID is kept in configuration.→

No JavaScript, Just Fast IR

Real sources and the IR the compiler produced from them, regenerated on every build of this site. The last tab is a loader the compiler refuses and the error it reports.

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

export async function load({ session }: Ctx) {
  const count = Object.values(session.cart).reduce((n, q) => n + q, 0n);
  return { count };
}
planIR
(let count (reduce (values (session cart)) 0 (fn (n q) (+ n q))))
Array methods such as reduce compile to IR nodes that the Rust server evaluates without a JavaScript engine.

When to pick FSR and when not to

Where another framework fits your project better, this says which one.

Pick FSR when

  • Your app is large. Each team deploys its own app. The host mounts them into one site with one session.
  • Your backend is already Rust. Pages are served by the same binary as your API.
  • You want no Node on the server. A deploy is one binary and a directory.
  • Server render time matters. 74,606 requests a second, 1.4x React on V8 for the same page.
  • Your team writes React or Vue. Both render on the server and share one store in the browser.
  • Some pages need no TypeScript. Tera templates and web components sit beside React pages.

Pick something else when

  • You need the largest ecosystem and hiring pool. Next.js has it.
  • The site is mostly content. Astro ships no JavaScript by default and has CMS integrations.
  • You deploy to serverless or the edge. SvelteKit, Nuxt and Astro have adapters for it. FSR runs as a server process.
  • You want to write components in Rust. Leptos and Dioxus compile Rust to WebAssembly.
  • You need a stable 1.0. FSR releases are 0.x and change between versions.
Best forLarge apps
SubsitesNative microfrontends
FrameworksReact, Vue, Tera and web components
SSR or CSRAutomatic
Performance74,606 req/s
DeploymentOne binary