Write the UI in whatever you want. FSR runs the application.

Your favorite framework for the web and the desktop, Ink for the terminal. FSR compiles it to a plan that a Rust binary runs, with no JavaScript runtime underneath.

$ cargo install snapfire_fsr_cli
Web React, Vue or Svelte
shop.example.com/cart

Your cart

Ember mug× 2Remove
Flint notebook× 1Remove
Ash tote× 1Remove
Total$64.00
Desktop the same pages in the OS webview
Storefront - Cart
ProductsCartOrders

Your cart

Ember mug× 2Remove
Flint notebook× 1Remove
Ash tote× 1Remove
Total$64.00
Terminal Ink components, no Node
storefront
❯ fsr dev app --terminal

╭────────────────────────────╮
│ Your cart                  │
│ Ember mug              × 2 │
│ Flint notebook         × 1 │
│ Ash tote               × 1 │
│                            │
│ Total               $64.00 │
╰────────────────────────────╯
 ↑↓ move  x remove  ctrl-c quit

the website and the desktop app call cart/page.loader.ts and removeItem; the terminal runs an Ink page through the same compiler

app/routes/cart/page.loader.ts
import type { Ctx } from "@snapfire/fsr/host";

export async function load({ services }: Ctx<"/cart">) {
  const cart = await services.shopping.getCart({});
  return { items: cart.items, total: cart.total };
}
app/routes/cart/actions.ts
import { action } from "@snapfire/fsr/host";
import type { ActionCtx } from "@snapfire/fsr/host";

export const removeItem = action(
  async ({ input, services }: ActionCtx<{ sku: string }>) => {
    await services.shopping.removeItem({ sku: input.sku });
  },
);
How it fits

Your UI on top, FSR underneath

On the web and the desktop each piece of UI can be written in a different framework. A terminal app is written in Ink. FSR compiles the routes, loaders and actions under them to a plan and a Rust host runs it.

Web and desktop UI
React .tsxVue .vueSvelte .svelteTera .teraWeb Components .ts
Terminal UI
Ink .tsx
FSR
compiled to a plan, run by Rust
  • routes app/routes
  • loaders page.loader.ts
  • actions actions.ts
  • contracts typed
  • services OpenAPI, Protobuf, GraphQL
One app runs as
A websiteserver-rendered, streamed, with islands
A desktop appthe same pages in the OS webview
Its own app runs as
A terminal appdrawn by FSR, no Node
Where it runs

Web, desktop and terminal

The website and the desktop window are one app with the same routes, loaders, actions and pages. A terminal app is its own app with an Ink root page, built and run by the same CLI.

Web

The shop as a website

The Rust server renders the page, streams the slow parts and hydrates only the islands. No JavaScript engine runs on the server.

shop.example.com/cart

Your cart

Ember mug× 2Remove
Flint notebook× 1Remove
Ash tote× 1Remove
Total$64.00
  • UIReact, Vue or Svelte islands on TSX or Tera pages
  • Runtimethe FSR host, rendering the compiled routes, loaders and actions
  • Deliverystreamed HTML, cached pages and prerendered files
  • Shipsone binary and a directory
12.5 µsto render a product page
116,484product pages served a second at 32 clients
0JavaScript engines on the server
1 + 1a binary and a directory to deploy
Benchmarks

FSR against five other frameworks

The same storefront built in FSR, Next.js, SvelteKit, Nuxt, Leptos and Dioxus and served on one machine. Render time is FSR’s renderer on its own against React.

Requests a second, product page, 32 clients

SnapFire FSR116,484
Leptos93,882
Dioxus32,289
SvelteKit13,800
Nuxt3,893
Next.js1,514

Higher is better. The same storefront in six frameworks on one machine. See the full tables

Trace the request that served this page
Reading the trace of this request…
Compared

How FSR compares

Pick a question. FSR's answer comes first with what backs it up, then how five other frameworks answer it.

FSR
Large apps

Independent teams each deploy their own site into one app, served by one Rust binary.

/fsrmain appthis site
/fsr/docsmountedits own app, built beside the shell
/fsr/learnmountedits own app, built beside the shell
One binaryserves all threeone layout, session and store
Next.jsReact SaaSSEO-heavy React apps and e-commerce.
NuxtVue appsVue content sites and SaaS.
SvelteKitInteractive UIsApps where bundle size matters.
AstroContent sitesBlogs, docs and marketing.
Remix 3Full-stack server appsA fetch-based server that renders HTML and hydrates only the components you mark.
FSR
React, Vue, Svelte, Tera and web components

All five render on the server when the build can lower them.

Reactserver-renderedislands and pages
Vueserver-renderedislands
Svelteserver-renderedislands
Teraserver-renderedpages with no TypeScript
Web componentsserver-renderedcustom elements as islands
Next.jsReactServer and client components.
NuxtVueVue single-file components.
SvelteKitSvelteSvelte components.
AstroReact, Vue, Svelte, Solid, PreactMixed on one page as islands.
Remix 3Its own JSX componentsremix/component, a JSX runtime of its own. React libraries do not work.
FSR
Automatic

Server-rendered by default. The build moves only what needs the browser into islands.

fsr build
rendered  routes/page.tsx                lowered  static
          src/ui/home/ReactCounter.tsx   lowered
          src/ui/RequestTrace.tsx        client
islands   src/ui/home/ServerCounter.tsx  server   2 handlers
Next.jsPer componentYou mark browser code with "use client".
NuxtPer routeRoute rules pick SSR, static or SPA.
SvelteKitPer routePage options turn SSR, CSR and prerendering on or off.
AstroPer islandStatic by default. You add a client directive to hydrate.
Remix 3Per componentPages render on the server. Components wrapped in clientEntry() hydrate.
FSR
SSR, SSG, ISR, streaming, SWR

Prerendered pages can be regenerated while the host serves, from the app's own Rust. Service data serves stale while it refreshes. Server islands and SPA navigation.

SSGfsr prerenderpages written as files
ISRHost::prerenderrewritten while serving
Streamingloading.tsxslow segments stream in
SWRcache.stalestale while refreshing
Next.jsSSR, SSG, ISR, streamingPartial prerendering mixes static and dynamic.
NuxtSSR, SSG, SPA, SWRRoute rules set each one per route.
SvelteKitSSR, prerender, CSRLoad functions can stream promises.
AstroStatic, SSR, server islandsServer islands render after the page.
Remix 3Streaming SSRFrames with a fallback stream in later. No prerender or static export.
FSR
116,484 req/s

The arena's product page with 32 clients. Serves on every core.

SnapFire FSR116,484
Leptos93,882
Dioxus32,289
SvelteKit13,800
Nuxt3,893
Next.js1,514
Next.js1,514 req/sThe same page from the standalone server, one Node process.
Nuxt3,893 req/sThe same page from the Nitro server, one Node process.
SvelteKit13,800 req/sThe same page from adapter-node, one Node process.
AstroNot measuredNot in the arena. Zero JavaScript by default.
Remix 3Not measuredNot in the arena. ES modules through an import map. Only client entries ship JavaScript.
FSR
Native microfrontends

Each team builds and deploys its own app. The host mounts it at a path inside the shared layout, session and store.

config/app.toml
[sites.docs]
artifact = "sites/docs"

[sites.learn]
artifact = "sites/learn"
Next.jsMulti-zonesSeparate apps joined by rewrites. Crossing zones reloads the page.
NuxtLayersLayers merge apps at build time. Separately deployed apps can share a domain under different baseURL paths.
SvelteKitBase path onlyEach app mounts under its own paths.base behind a proxy. Crossing apps is a full page load with no shared layout or state.
AstroBase path onlyEach app mounts under its own base behind a proxy. Links between them are full page loads.
Remix 3Not built inrouter.mount() splits one deployment. A separately deployed part sits behind fetch-proxy.
FSR
One binary

Copy it and a directory to any server. No Node. The arena storefront is 25.7 MiB to deploy. The same app opens in a desktop window too.

dist/
shop                the binary
config/             app.toml
app/generated/      plan.sexp
serve/static/       static files
Next.jsVercel firstRuns on any Node server. The arena storefront is 151.8 MiB to deploy with Node.
NuxtMany runtimesNode, workers and static hosts. The arena storefront is 109.7 MiB to deploy with Node.
SvelteKitAdaptersNode, static, serverless and edge. The arena storefront is 108.8 MiB to deploy with Node.
AstroStatic or SSRAdapters for most hosts.
Remix 3Any Fetch runtimeA Request to Response handler for Node, Bun, Deno or Workers. The starter needs Node 24.
Islands

React, Vue, Svelte, Web Components or Tera

An island is the interactive part of a page. Write it in React, in Vue, in Svelte or as a web component. A server island runs on the server instead, so the browser downloads no code for it. Pages are TSX or Tera templates. Every kind of island shares the page and the store.

import { useStore } from "@snapfire/fsr/client/react";
import { demoCount } from "@src/store";

export function ReactCounter() {
  const [n, setN] = useStore(demoCount, 0);
  return (
    <div className="forge-counter">
      <button onClick={() => setN(n - 1)}>−</button>
      <output>{n}</output>
      <button onClick={() => setN(n + 1)}>+</button>
    </div>
  );
}

// in the page
<Island when="visible"><ReactCounter /></Island>

React island

The count is a store key. The Vue counter reads the same one.

0
frameworkReact
component code runs inthe browser
// A server island written in React
import { useStore } from "@snapfire/fsr/client/react";
import { demoCount } from "@src/store";

export function ServerCounter() {
  const [n, setN] = useStore(demoCount, 0);
  return (
    <div className="forge-counter">
      <button onClick={() => setN(n - 1)}>−</button>
      <output>{n}</output>
      <button onClick={() => setN(n + 1)}>+</button>
    </div>
  );
}

// in the page
<Island when="visible" mode="server"><ServerCounter /></Island>

Server island

Each click is a request. The server runs the handler with the store's count and sends back a patch.

0
code sent to the browsernone
component code runs inthe server
<script setup lang="ts">
import { useStore } from "@snapfire/fsr/client/vue";
import { demoCount } from "@src/store";

const n = useStore(demoCount, 0);
</script>

<template>
  <div class="forge-counter">
    <button @click="n.value--">−</button>
    <output>{{ n.value }}</output>
    <button @click="n.value++">+</button>
  </div>
</template>

Vue island

The same store key as the React counter, so the two show one count.

0
frameworkVue
component code runs inthe browser
<script lang="ts">
  import { useStore } from "@snapfire/fsr/client/svelte";
  import { demoCount } from "@src/store";

  const n = useStore(demoCount, 0);
</script>

<div class="forge-counter">
  <button onclick={() => ($n -= 1)}>−</button>
  <output>{$n}</output>
  <button onclick={() => ($n += 1)}>+</button>
</div>

Svelte island

The same store key again. useStore is a Svelte store, so $n reads it and writes it.

0
frameworkSvelte
component code runs inthe browser
import { get, set, subscribe }
  from "@snapfire/fsr/client/store";
import { demoCount } from "@src/store";

const count = () => get(demoCount) ?? 0;

export class ForgeCounter extends HTMLElement {
  connectedCallback() {
    const out = this.querySelector("output")!;
    const [down, up] = this.querySelectorAll("button");
    down.onclick = () => set(demoCount, count() - 1);
    up.onclick = () => set(demoCount, count() + 1);
    out.textContent = String(count());
    subscribe(demoCount, (n) => (out.textContent = n));
  }
}
customElements.define("forge-counter", ForgeCounter);

// in the page
<Island when="visible"
  define="@src/elements/forge-counter.ts">
  <forge-counter>
    <div className="forge-counter">
      <button aria-label="Decrease">−</button>
      <output>0</output>
      <button aria-label="Increase">+</button>
    </div>
  </forge-counter>
</Island>

Web Component

A custom element on the same store key. The browser upgrades the markup the server wrote.

0
frameworkWeb Components
component code runs inthe browser
<!-- routes/demos/counter/page.tera -->
<div class="forge-counter" id="tera-counter">
  <button hx-get="/demos/counter?__fragment"
    hx-vals='{"by": -1}' hx-target="#tera-counter"
    hx-swap="outerHTML">−</button>
  <output>{{ n }}</output>
  <button hx-get="/demos/counter?__fragment"
    hx-vals='{"by": 1}' hx-target="#tera-counter"
    hx-swap="outerHTML">+</button>
</div>

// routes/demos/counter/page.loader.ts
export async function load({ query }: Ctx) {
  return { n: Number(query.n ?? "0")
    + Number(query.by ?? "0") };
}
export const store = ({ data }) =>
  ({ "home/count": data.n });

Tera and htmx

Each click asks the server for the counter. Tera renders it and htmx swaps it in, with the new count for the store.

0
templateTera
rendered bythe server
From empty to deployed

Four commands

The CLI makes the project, serves it with reload, checks it and writes the tree you copy to a server.

zsh
  1. Make a projectfsr new shop --with react writes the config, routes, a layout and an import map.
  2. Run itfsr dev rebuilds and reloads the page when a file changes.
  3. Check itfsr doctor runs twenty-two checks for failures development hides.
  4. Bundle itfsr bundle writes dist/ with fsr-server in it. Copy it to the server and run ./fsr-server.
In the host

Built into the host

The stock host handles sessions, identity, service calls, caching and deployment checks, so the application code doesn't implement them.

sites

Micro-frontends

Mount one application inside another at a prefix. It shares the layout, session and store, in one document with one copy of React.

shop.example.com
/docs a second app
deploy

Deploy by copying

A binary and a directory. No Node and no npm ci on the server.

dist/
  fsr-server  the binary
  config/
  app/        plan.sexp, read at boot
  serve/      static files
identity

Server-held tokens

The host stores bearer tokens and attaches them to service calls. TypeScript never sees one.

services

Typed service calls

Clients generated from OpenAPI, Protobuf and GraphQL documents, called as services.shopping.placeOrder().

pages

Pages in Tera

Write a page as a Tera template when it needs no TypeScript.

cache

Caching

Cache a whole page or just the slow part of it. Service data serves stale while it refreshes.

stream

Streaming and prerendering

Send the shell first and stream a slow section. Render a blog at build time and serve it as files. An app with its own Rust can regenerate it while the server runs with Host::prerender.

Micro-frontends

This site runs on FSR

The docs and the tutorials are separate FSR applications mounted inside this one. Each has its own routes and content. They share this site's layout, session and store in one document, so moving between them is not a full page load.

[sites.docs] and [sites.learn] in config/app.tomlone document, one copy of React
Desktop

The shop as a desktop app

FSR opens the operating system's own webview on the pages, loaders, actions and islands the website serves. fsr serve --desktop runs the Rust host in the window's process. Under fsr dev --desktop the dev loop runs the host beside the window.

Storefront - Cart
ProductsCartOrders

Your cart

Ember mug× 2Remove
Flint notebook× 1Remove
Ash tote× 1Remove
Total$64.00
  • UIthe website's pages and islands, drawn by WebKit on macOS and Linux or WebView2 on Windows
  • Hostthe FSR host, reached over loopback or, with fsr serve --desktop, served in process at app://localhost
  • Windowtitled from the app name and the page's <title>, with a menu bar, Back, Forward and Reload
  • BridgeopenFile() from @snapfire/fsr/desktop opens the system file dialog and openExternal(url) hands a link to the system
zsh
❯ cargo install snapfire_fsr_cli --features desktop
❯ fsr dev app --desktop
  the window stays open across rebuilds
❯ fsr serve app --desktop
  exits when the window closes
config/app.toml
[desktop]
name = "Storefront"
serve = "scheme"    # fsr serve: app://localhost, no port
cookies = "disk"    # kept across launches

Experimental. Tried on macOS. The Windows and Linux code is written but not yet built or run. Packaging and signing do not exist yet.

Against Electron

No bundled Chromium, no Node.js

Electron ships a Chromium and a Node runtime inside every app and takes their security releases with them. FSR uses the webview the operating system already has and runs the Rust host in the same process.

ElectronFSR desktop
Draws the UIits own copy of ChromiumWebKit or WebView2, from the OS
Runs the applicationNode.jsthe FSR host, in Rust, in the same process
Data and actionsIPC handlers written for the desktopthe loaders and actions the website uses
JavaScript runtime carriedyesnone
Terminal

The shop in a terminal

The root page is an Ink component. FSR lowers it to IR and runs it in its Rust binary: layout through taffy, each key steps the component's state and only the changed cells are redrawn.

storefront
❯ fsr dev app --terminal -- --limit 3

╭────────────────────────────╮
│ Your cart                  │
│ Ember mug              × 2 │
│ Flint notebook         × 1 │
│ Ash tote               × 1 │
│                            │
│ Total               $64.00 │
╰────────────────────────────╯
 ↑↓ move  x remove  ctrl-c quit
import { useState } from "react";
import { Box, Spacer, Text, useInput } from "@snapfire/fsr/terminal/ink";
import type { RootProps } from "@generated/client";

export default function Cart({ items }: RootProps) {
  const [rows, setRows] = useState(items);
  const [at, setAt] = useState(0);
  useInput((input, key) => {
    if (key.upArrow) setAt(Math.max(at - 1, 0));
    if (key.downArrow) setAt(Math.min(at + 1, rows.length - 1));
    if (input === "x") setRows(rows.filter((_, i) => i !== at));
  });
  return (
    <Box flexDirection="column" borderStyle="round" paddingX={1}>
      <Text bold>Your cart</Text>
      {rows.map((r, i) => (
        <Box key={r.sku}>
          <Text color={i === at ? "yellow" : undefined}>{r.name}</Text>
          <Spacer />
          <Text>× {r.qty}</Text>
        </Box>
      ))}
    </Box>
  );
}
import type { Ctx } from "@snapfire/fsr/host";
import { file } from "@snapfire/fsr/host";

export async function load({ params }: Ctx<"/">) {
  const cart = JSON.parse(file.read("cart.json"));
  const limit = Number(params.limit ?? 10);
  return { items: cart.items.slice(0, limit) };
}
{
  "items": [
    { "sku": "mug", "name": "Ember mug", "qty": 2 },
    { "sku": "notebook", "name": "Flint notebook", "qty": 1 },
    { "sku": "tote", "name": "Ash tote", "qty": 1 }
  ]
}
Ink without Node

Ink's API, run by FSR

Claude Code, Gemini CLI, Qwen Code, Shopify CLI and the TypeScript Codex CLI draw their terminals with Ink on Node. FSR runs the same components with no Node and no React, so Node's security releases don't apply to it.

built in

Spinners, inputs and text width

FSR has its own Spinner, TextInput, ms, stripAnsi, stringWidth and wrapAnsi under @snapfire/fsr/terminal. fsr migrate points imports of ink-spinner, ink-text-input and the rest at them. No npm install and no node_modules.

machine

Does what a CLI does

file and path from @snapfire/fsr/host and system from @snapfire/fsr/terminal read the machine. Spawn git, keep timers running and read the arguments after -- as params.

dev

Edit it while it runs

Save a component and the terminal redraws it with its state kept. No restart and no lost place.

Experimental. Only routes/page.tsx runs. Transform and measureElement are not supported yet. The loader runs once when the session opens, with no session and no service clients. Install it with cargo install snapfire_fsr_cli --features terminal.

Migrating in

Bring the app you have

fsr migrate reads a Next.js or Ink application and writes it as a new FSR project. It never writes to the original and names every line it could not rewrite.

  • RoutesApp Router or Pages Router, getServerSideProps and async pages moved into loaders
  • Actionsserver actions as FSR actions with typed inputs, Prisma calls as a service
  • Componentsnext/link, next/image and metadata as Link, Picture and the loader's meta
  • Inkroutes/page.tsx renders the component the entry rendered and the code that built its props becomes the root loader
zsh
❯ fsr migrate ./my-next-app ./my-fsr-app
from      Next.js App Router
wrote     14 files under ./my-fsr-app
note      app/routes/layout.tsx: the root layout's <head> was taken out;
          a route's head comes from its loader's meta
next      fsr add ./my-fsr-app/app clsx date-fns
next      fsr check ./my-fsr-app/app

❯ fsr migrate ./my-ink-tool ./my-fsr-tool

Next.js's own examples are migrated and built as a test. Many build afterwards and the build names what stops the rest.

Security

Server attack surface

No JavaScript runs on an FSR server and npm never runs on your machine, so these classes of bug have nothing to reach. A fix in a Rust dependency ships with cargo update, without waiting for a SnapFire release.

Deserializing into a JavaScript runtime

On Node: A decoder that builds JavaScript objects from a request runs inside the server's runtime. React2Shell made the RSC decoder run code without sign-in. node-serialize ran any function a payload carried.

FSR: A request decodes against the action's typed contract into Rust values. There is no JavaScript heap for a crafted payload to build objects in.

Precedents: React2Shell, CVE-2025-55182 · node-serialize, CVE-2017-5941

Bugs in the runtime and the C it bundles

On Node: Node bundles V8, llhttp, undici, zlib and Brotli. A bug in any of them is in every Node server until Node ships a release and you deploy it.

FSR: No Node or Bun on the server, so none of that code runs in the host.

Precedents: Node Brotli decoder, CVE-2026-111497

Prototype pollution

On Node: A request body with a __proto__ or constructor.prototype key that reaches a deep merge writes to the prototype every object in the process shares. That can flip a check like user.isAdmin for every later request. Through a template engine it can lead to code execution.

FSR: The IR has no prototypes, so there is no shared object to write through.

Precedents: lodash, CVE-2019-10744

Code injection through eval and templates

On Node: eval, new Function and template engines turn strings into code while the server runs. In ejs 3.1.6 a request option became an operating system command when the template compiled.

FSR: The IR is a fixed set of operations written at build time. A request cannot add one.

Precedents: ejs, CVE-2022-29078

npm install scripts on your servers

On Node: A Node deploy runs npm install or npm ci on the machine that builds or serves the app. npm runs every dependency's preinstall and postinstall script there by default, with that machine's credentials. The compromised ua-parser-js installed a cryptominer and a password stealer this way. Shai-Hulud stole npm, GitHub and cloud tokens and published itself into its victims' other packages.

FSR: Packages are vendored for the browser and never run in the host. fsr add runs no install script. A package added with --published is checked against the registry's integrity hash.

Precedents: ua-parser-js, 2021 · Shai-Hulud, 2025

Regular expression DoS in routing

On Node: path-to-regexp turns routes into regular expressions. A route with two parameters in one segment backtracked on a crafted URL and held Node's one event loop, so every other request waited.

FSR: Routes match through a radix tree with no regular expression. A pattern in your own code that nests unbounded quantifiers is refused instead of run.

Precedents: path-to-regexp, CVE-2024-45296

The Server Attack Surface row compares all eight frameworks. What FSR removes and what it leaves to you →

Also from SnapFire

Other projects

SnapFire Compiler

TypeScript and CSS to browser-native ES modules, with no Node and no node_modules.

snapfirec Read on GitHub
snapfire

Tera templates over Actix with live reload that needs no configuration.

cargo add snapfire Read on GitHub

Build your first page

The first tutorial starts from an empty directory and ends with a page served by Rust.