SnapFire FSR, Full Stack Runtime, First Look: full-stack TypeScript framework with no Node.js in it!

Updated on Sep 04 2026 at the 14th hour

DISCLAIMER: Expressed views on this blog are my own.

Sometimes the stars align, build

There is no Node.js in this stack. Not at build time, not at request time, not in the container. The build is a Rust binary, the server is a Rust binary, and the thing you deploy is one file with no node_modules next to it.

You still write TypeScript. You write .tsx pages, a load function per route, actions, layouts, middleware, all of it in the shapes you already know from Next.js and Remix. That part is deliberately unremarkable. What is underneath it is not.

This is part of a longer quest of mine to get Node.js out of my stack entirely. Not out of my editor, not out of my language choice, out of my runtime. I want to write TypeScript and ship a binary. I've always wanted to get rid of node_modules, I used to build SEA so I could ship one binary. to the server.

What you actually write

Here is a real loader from the storefront example that ships with FSR:

import type { Ctx, DataOf, MetaCtx } from "@snapfire/fsr";

export async function load({ query, services }: Ctx<"/">) {
  const products = await services.shopping.listProducts({ q: query.q, category: query.category, tag: query.tag });
  return { products, q: query.q, category: query.category };
}

export const meta = ({ data }: MetaCtx<DataOf<typeof load>>) => ({
  title: data.q ? `Results for ${data.q} · Shopping` : "Today's picks · Shopping",
});

And a real action:

export const addToCart = action(async ({ input, session }: ActionCtx<AddToCart>) => {
  const key = String(input.product_id);
  const wanted = (session.cart[key] ?? 0n) + input.quantity;
  if (wanted <= 0n) delete session.cart[key];
  else session.cart = { ...session.cart, [key]: wanted };
  const count = Object.values(session.cart).reduce((n, q) => n + q, 0n);
  return { lines: session.cart, count };
});

Nothing exotic. Typed session, typed input, typed service call. Your editor checks all of it.

Now the part that matters: at request time, no JavaScript engine executes either of those.

Your loader is not a program

Here is the observation the whole framework is built on. Most of what a loader says is not really a program. It names a service method, pulls arguments out of the request, puts the result under a key, reads or writes a session value. That is data wearing function syntax.

So the build reads it and writes it down. A loader body becomes a typed expression tree over a closed set of node kinds, and the Rust runtime executes the tree directly. Thirty five expression kinds, eight render kinds, twenty nine builtins. params, query, session, identity, arithmetic, comparisons, ternaries, template strings, map, filter, reduce, Object.entries, Math.round, toFixed, encodeURIComponent. The list is closed on purpose and it grows only when a real application shows up with a body that genuinely needs something and cannot say it another way.

TypeScript is the syntax here, not the thing being interpreted. + on two typed integers is not JavaScript + with all its coercion baggage, because every input to a body is typed by us: params from the route, session from your schema, services from the contract, input from the action's declared type. The build knows what everything is before the first request arrives.

The payoff is not just speed. A loader the runtime can see as data gets parallelised with its siblings for free, cached on its inputs, validated against the service contract at build time, and traced without you instrumenting anything. A loader that an engine executes is an opaque box until it runs. SQL replaced loops for exactly this reason. Prisma and Drizzle turn method chains into query plans today rather than running them line by line. This shape is not new, it is just the first time it has been applied to the whole request.

Components lower the same way. Your .tsx renders to HTML in Rust with no engine in the path, then the browser hydrates the module over the markup it already has.

When it does not lower, you get told

This is where most "compile your framework away" projects quietly fall apart, so let me be clear about it.

If a body falls outside what the build recognises, it is residue, and residue runs in QuickJS in process. About a megabyte, no Node, no npm, no sidecar, no second process. Modules are precompiled to bytecode at build time and a warmed context is cloned per request. Identical semantics, and you are told which body it was and which line decided it.

The boundary is a diagnostic, not a wall. That means the recognised subset can be exactly as large as real applications need and no larger, instead of being padded out defensively until it is a whole second language.

And you never have to guess which is which, because the server prints it at boot:

sources   catalog    lowered     app/routes/index/page.loader.ts
          product    lowered     app/routes/product/page.loader.ts
          pricing    rust        override
          legacy     engine      app/routes/legacy/page.loader.ts:2  imports slugify

A name nothing claims is a startup error, not a 500 at 3am. A name claimed by both TypeScript and Rust is a startup error unless you marked it an override deliberately.

The other half: Rust, one name at a time

The failure mode of every tiered framework is that dropping a level means owning everything below it. You eject once and now you own the build.

Here the unit is one id. Your loader is called pricing, the plan file names pricing, and if you want a Rust function to answer pricing you say so:

fn main() -> std::io::Result<()> {
  snapfire_fsr::App::from_build("dist")
    .source_override("pricing", pricing_loader)
    .action("checkout", checkout)
    .transport("shopping", in_process(catalog))
    .serve(("0.0.0.0", 8080))
}

Every other loader in the application is still TypeScript and stays TypeScript. The frontend team's source tree does not change. One page can render through React while its neighbour renders through Tera, because the runtime selects per module.

This is the actual thesis. Frontend and full-stack engineers write TypeScript and never touch routing, transport or sessions. Platform engineers write Rust and never touch application code. The two never trade places by accident, and neither one gets held hostage by the other's tooling.

Does it go fast

Rendering the same three storefront pages, byte-identical output both ways, Rust interpreter against the same components running in QuickJS:

Page Rust IR QuickJS
catalog 999 µs 1.77 ms
product 130 µs 472 µs
cart 129 µs 506 µs

Cold context in QuickJS is 20 ms. The Rust path has no cold context, because there is no context.

I want to be honest about the shape of that number. It is one benchmark, on one machine, on one example application, and the interpreter is on its first optimisation pass with several more mapped out. Take it as evidence that the approach is not slower than the thing it replaces, which is the claim I actually need it to support.

What is real today

The storefront example is a working application: file system routes, dynamic segments, layouts with their own loaders, route handlers, actions, typed sessions, CSRF, auth, middleware, streaming, client side navigation with prefetch, static generation, a render memo cache, not-found and error pages, and a generated typed client so the browser calls your actions like functions. It builds with snapfirec and serves with fsr serve.

It is early. It is one person's project and it is exploratory in the parts that are still exploratory. There is a lot of Next.js surface area I have not built and some I do not intend to.

What this is not

It is not a Next.js clone with a Rust logo on it. It is not a transpiler that turns your TypeScript into Rust, which is not a real thing anybody can do and I am not claiming to have done it. It is not asking you to learn a new language to get off Node.

It is a bet that the framework layer of a web application is mostly declarative, that a runtime which can see your application as data can do considerably more for you than one that can only run it, and that the last hard dependency on Node.js in a modern frontend stack is a habit rather than a requirement.

More to come. The interpreter, the lowering rules and the payload format each deserve their own post.

Sometimes the stars align.

  • Snapfire Compiler github plays a large part in enabling typescript without node.js. Thanks large in part to swc.
  • Fibre Cache github provides much needed versatile, high performance caching.
  • Fibre Logging github makes it really easy to just log.
  • C5Store github is just delightful configuration. Easy to override, integrates env vars, json, yaml, toml, embedded secrets, external config providers. It's just awesome, its the last configuration library I will ever use.
You just read "SnapFire FSR, Full Stack Runtime, First Look: full-stack TypeScript framework with no Node.js in it!". Please share if you liked it!
You can read more recent posts here.