New React and Vue islands on one page

Web apps in TypeScript, served by Rust.

Write routes, loaders, actions and pages the way other frameworks do. FSR compiles them at build time and the Rust server runs the result, with no JavaScript engine on the server.

$ cargo install snapfire_fsr_cli
12.5 µsto render a product page
74,606requests a second at 8 requesters
0JavaScript engines on the server
1 + 1a binary and a directory to deploy
Compiler output

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

FSR against React on the server

The same pages with the same props, rendered by the FSR interpreter, by React in V8 and by React in QuickJS.

Requests a second, 8 requesters

FSR74,606
React, V8 processes54,353
React, V8 workers5,549
React, QuickJS3,305

Higher is better. See the full tables

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/docsmountedbuilt and deployed on its own
/fsr/learnmountedbuilt and deployed on its own
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, Tera and web components

All four render on the server.

Reactserver-renderedislands and pages
Vueserver-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 regenerate while the host serves. 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
74,606 req/s

1.4x React on V8 for the same page. 12.5 µs a render.

FSR74,606
React, V8 processes54,353
React, V8 workers5,549
React, QuickJS3,305
Next.jsExcellentPartial prerendering and edge rendering.
NuxtStrongNitro server with hybrid rendering.
SvelteKitEliteCompiles components away. Tiny bundles.
AstroEliteZero JavaScript by default.
Remix 3Unbundled modulesES 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.

dist/
shop                the binary
config/             app.toml
app/generated/      plan.sexp
serve/static/       static files
Next.jsVercel firstRuns on any Node server.
NuxtMany runtimesNode, workers and static hosts.
SvelteKitAdaptersNode, static, serverless and edge.
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, Web Components or Tera

An island is the interactive part of a page. Write it in React, in Vue 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
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 thirteen checks for failures development hides.
  4. Bundle itfsr bundle writes dist/. Copy it and the binary to the 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

Mounted sites

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/
  shop        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 and Protobuf, 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, serve it as files and regenerate it while the server runs.

Mounted sites

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.

[sites.docs] and [sites.learn] in config/app.tomlone document, one copy of React
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.