# What TanStack Start actually asks of its server

Author: Chris Chabot
Canonical URL: https://chabot.dev/blog/what-tanstack-start-asks-of-its-server/
Published: 2026-11-03

Porting TanStack Start to a Rust host shows how much of its server contract lives behind fetch: Nitro’s rules, cache behavior and the things a port must refuse.

[TanStack Start](https://tanstack.com/start/latest) summed up its portability in two short sentences on its own site when I went to port it: "Keep the routes. Change the output." The project I ported used Start's Vite build. Its build produces client assets and a server bundle whose whole interface is `fetch(Request)` returning a `Response`, deployment targets are Vite plugins composed into the build, and [Nitro](https://github.com/nitrojs/nitro), the server toolkit that also sits under Nuxt and SolidStart, is the recommended adapter for hosts without a plugin of their own. Read from that angle, a framework's server contract looks like one function.

In late August 2026 I went to find out how much more there was to it, by putting Start on Ferrite, my Rust application platform, which can't offer even that one function. There is no general-purpose JavaScript server to run the bundle behind `fetch`. An application gets one Cargo dependency. The gateway hedges requests, a reply is a single buffer, a store's TTL is a hard cliff, and static assets ship as text. Everything Start expected of a server would have to be rebuilt in Rust, changed on purpose or refused.

The port is possible because of how Nitro v3 is built. It compiles routing, rules and assets to data at build time and its runtime consumes that data, and while the Rust application cannot execute that server bundle, it can certainly read a manifest. So the work has two halves with one frozen seam between them. A JavaScript build adapter takes the real output of `vite build` and emits a versioned JSON manifest, compiled into the application as a Rust string constant, along with the site's files packed as text, which are seeded into blob storage at boot and served by the gateway with strong ETags. A Rust source package, ported from Nitro's router, route rules, storage, cache, tasks and asset handling, consumes the manifest. A version mismatch is refused, a handler id with no registered handler is a build error, and a test on the JavaScript side carries a copy of the Rust field lists and fails when they drift. For the demo application, built from a real Start project, the manifest is small enough to describe in a sentence: a CORS rule on `/api/**` with a 204 preflight, a 307 redirect from `/old/**` to `/about/`, 13 assets split between `no-cache` and `immutable`, an SPA shell at `/_shell.html`, a `/_serverFn/:id` route for GET and POST, and one server function id.

If the contract ended at the data this would have been a small job, but the meaning of the data lives in behavior no manifest records. Rules have to run in Nitro's order, CORS, then headers, then redirect, then proxy, then cache, and the terminating ones must never call `next()`. Each rule kind replaces its whole value and doesn't merge option by option, clearing a rule takes a `reset` list where the JavaScript uses `false`, and GET rules also register under HEAD. Nitro hands its caching to ocache; the port also has to preserve the documented cache-key format, `{expires, value, mtime, integrity}` entries and stale-while-revalidate arithmetic. Conditional requests follow RFC 7232's precedence, with weak comparison, `*` and IMF-fixdates at second granularity, and a prerendered `/about/` page answers a repeat visit with a 304 and an empty body. The designs here are upstream's, Nitro's, [h3](https://github.com/h3js/h3)'s, [rou3](https://github.com/h3js/rou3)'s and ocache's, and what I contributed is a Rust port and the packaging around it. It is still where most of Start's server environment turned out to live, and none of it is visible from `fetch(Request)`.

Some of that contract can't be honored on this host, and at that point porting turns into a series of decisions. Nitro's stale-while-revalidate serves the stale entry and refreshes it in the background, relying on `event.waitUntil` on edge workers to keep that work alive after the response. With one buffered reply and no `waitUntil`, there is no background to refresh in. So the first request to find a stale entry claims the flight and gets the stale value, and the next request runs the handler inline. One visitor pays in latency for work that would otherwise have happened out of sight, a deviation from Nitro that I made deliberately. Refresh coordination is local to each runtime instance, so two instances can refresh the same key.

Incremental static regeneration ran into storage in the same way. A store TTL on Ferrite is a hard cliff and blob mounts have no TTL primitive at all, while a stale ISR page has to remain available, because deleting it turns the page into a 404. So expiry is soft and storage never removes an entry: stale, but never absent. Keys are paths only, with query dimensions refused at build time. ISR wins over `cache` rules, and lookup goes to the `isr` mount, then the `cache` mount, then straight through as a miss, with no per-instance memory standing in. A test with a manual clock walks the cycle: the page renders with stamp 1, at 30 seconds it comes from storage and a conditional request gets a 304, at 61 seconds it is stale and still stamp 1, the next request refreshes it to stamp 2, and stamp 2 is what gets served from then on. Everything here is a whole buffer, and none of it streams.

That leaves the one part of Start that really is JavaScript, the function a developer hands to `createServerFn`. Embedding the SSR bundle would have brought React and the router along, and Start's own build output offered a better seam. Each `createServerFn` compiles into a provider module whose call ends up as `.handler(extractedRpc, ORIGINAL_USER_FN)`, so the developer's original function travels as the second argument. The adapter replaces the provider's import with a shim that captures the method, validator and original function for each id, bundles the result with esbuild, keeping the developer's own imports as real code and using the application's real [seroval](https://github.com/lxsmnsyc/seroval) for the wire format, and compiles it in as a Rust constant. QuickJS runs it behind `/_serverFn/:id`. QuickJS executes synchronously and the platform's host calls are asynchronous, so when a script calls out, execution suspends, Rust services the call, and the script replays deterministically with the recorded results fed back in, which makes each host call once-only within an invocation but not across a crash. The first version of this path barely uses that machinery: capabilities are deny-all, so a function that requests one gets a `capability_denied` error, `FormData` gets a 501, and a Promise or stream result gets a 500.

Where the port can't honor something, it refuses at build time. `.middleware(...)`, unsupported SSR exports, a `createServerFn` it can't extract, `stream` or `maxResolveTime`, a bad redirect status, proxy targets it can't honor, a malformed `isr` rule and server functions with no JavaScript engine mounted all fail the build. The alternative is a port that accepts `.middleware()` and quietly ignores it, which looks fine until the ignored middleware is the one checking permissions, and I would rather take the build failure every time.

So how much of the server environment had to be recreated? Routing, rule semantics, cache arithmetic, conditional requests and asset handling came across nearly whole. Background revalidation changed shape. Request-time React rendering, WebSockets and a dev server didn't come at all, and multi-frame streaming stays a seam held for later, so pages are prerendered or start from the SPA shell.

`fetch(Request)` is the interface Start shows a host, and the contract a host has to satisfy is most of what Nitro does behind it.
