There used to be a page here for each runtime, and a server file generated to match. There is no server file now, and no choice about it either: Nitro builds the server around the route tree, and choosing where an app runs is choosing a preset.
import { defineConfig } from 'vite';
import { nitro } from 'nitro/vite';
import react from '@vitejs/plugin-react';
import { rscKit } from '@rsc-kit/core/vite';
export default defineConfig({
plugins: [
nitro({ preset: 'bun', serveStatic: 'inline' }),
rscKit(),
react(),
],
});bun create rsc-kit@latest my-app writes that for you. Changing where it deploys is
changing the one string.
The presets
rsc-kit asks about three, because those are the ones it can check for you.
Nitro carries many more and they need nothing from
this package — a preset is a string, not an integration.
--host |
preset | what you get |
|---|---|---|
bun |
bun |
.output/server/index.mjs, and bun run compile for a single binary |
node |
node |
.output/server/index.mjs, run with node |
worker |
cloudflare_module |
a Worker, plus the wrangler.json and _headers Nitro generates |
Everything else — Vercel, Netlify, Azure, Deno, AWS Amplify — is the same change:
nitro({ preset: 'vercel', serveStatic: 'inline' })Streams on serverless platforms
events() routes and refreshOn hold a connection open. On Workers and
serverless functions each isolate or instance watches for its own tabs, a
platform’s request limits cut streams (they reconnect and catch up), and
versions must live in a shared store. On Workers and serverless
has the details.
Running it
npm run dev # vite is the renderer; nothing is prebuilt
npm run build # writes .output/
npm run start # runs .output/server/index.mjsA Worker has no start, because it is deployed rather than started:
npm run preview # wrangler dev, on workerd
npm run deploy # nitro deploy --prebuiltCompiling to a single binary
Bun only, and the whole application ends up inside one file — engine, route tree, frozen pages and assets, the precompressed variants included:
npm run compile # builds, then bun build --compile --splitting .output/server/compile.mjs
./dist/appcompile is vite build && bun run compile:binary, and compile:binary is
the bun build --compile alone. After a build has run, bun run compile:binary compiles what is already there; a deploy runs that one, so it
builds once. An older app whose compile holds both steps works the same but
builds twice on a deploy, and the build says so until it is split.
compile.mjs is written by the build beside the server. It is what puts the
frozen pages inside the binary: the server reads them through a computed
import, which a compile cannot see, and this entry imports them by name and
hands them over before starting the server. Compile index.mjs instead and
the binary still works — it renders those pages live. You deploy the binary
and nothing else: not .output/public, not .output/server. A Dockerfile
copies dist/app and runs it. One exception: an app with
native packages needs a directory the binary
can write to, because it writes them there the first time it starts.
./app starts the server. If the app has src/commands,
./app migrate runs one of them instead, without starting the server, and
./app --help lists them. The same files run from a plain build as
node .output/server/commands.mjs migrate, so a release step or a worker does
not need the binary or Bun. The scaffold compiles with --splitting, so that a
stop that arrives while Bun parses the binary is held for a worker rather than
ending the process: where a stop is held from.
It builds first on purpose. Compiling whatever .output happens to hold means
a binary one version behind the source with nothing to say so — and on a
project that has never been built, an ENOENT naming a path the app did not
write.
Partially prerendered pages are resumed inside the binary exactly as they are
by the built server. Compiling merges every module into one scope and renames
whatever collides — --keep-names does not prevent that — so the build pins
each component’s name as a displayName the shell can rely on. No flag.
--compile implies --production; --target bun is the default. --bytecode
compiles the JavaScript ahead of time for a faster cold start at the cost
of a larger binary — a production port measured 275 ms → 72 ms to the first
healthy response for 81 MB → 117 MB, and left it out, since on a pod the
pull costs more than the boot saves.
Bytecode is CommonJS, and in CommonJS import.meta does not exist: Bun
lowers import.meta.url, .dirname, .dir and .main, and a module
using any other form — .env, .filename, .resolve, the object itself —
fails the step. Bun then reports “Failed to generate bytecode”, names no
file, and exits 0, so a CI ships a binary that dies at boot with
“import.meta is only valid inside modules”. The engine’s own output is
clean; a dependency’s import.meta.env or import.meta.resolve is the
usual cause. --bytecode --format=esm applies regardless: the same
bytecode, as a module.
Native packages in a binary
bun build --compile bundles a package’s JavaScript into the binary, and a
native package then looks for its .node file beside a module that is no
longer on disk. Compiled as-is, a binary that uses sharp starts, then dies
the first time it imports sharp. So when the build traced
native packages, the binary
carries those packages’ files. On its first start it writes them to disk and
loads them from there:
[rsc-kit] native packages: wrote 189 files to /srv/app/runtime/c3a3844a949c748cThey go to $RSC_KIT_RUNTIME_DIR/<hash>/, or to a folder beside the binary
when the variable is unset. The binary needs to be able to write there. On a
read-only filesystem, point the variable at a writable directory:
RSC_KIT_RUNTIME_DIR=/tmp/app ./dist/appThe hash is of the packages’ files. That makes one directory safe to share:
- A later start of the same build writes nothing. It finds its folder and uses it.
- Two copies starting at once do not clash. Each writes to a temporary folder and renames it into place. Whichever rename comes second throws its own copy away and uses the first.
- Two versions side by side during a deploy each have their own folder. A running binary touches its folder every hour. Each start removes other folders that nothing has touched for a day, so an old version still serving keeps its files.
Only what has to be on disk goes there:
- a package with a
.nodeor.wasmfile, or one built for a single platform; - a package that depends on one of those, because it finds it from its own location;
- whatever those depend on;
- any package you name in
serverExternalPackages, for one that reads a data file from beside its own code.
Each goes with its whole folder, and its JavaScript runs from disk too, since
that is how it knows where its files are. Everything else, the app and pure
JavaScript packages like tslib, stays in the bundle. An app with no native
packages compiles exactly as before, and writes nothing.
What it cost on the CI’s linux-x64 runner, for a fresh scaffold, and for the
same app with sharp and heic-convert:
| binary | start to first answer | |
|---|---|---|
| without native packages | 88.4 MB | 97 ms |
with sharp and heic-convert, first start |
117.1 MB | 195 ms |
with sharp and heic-convert, later starts |
117.1 MB | 170 ms |
The binary grows by the packages’ size, 29 MB here, most of it libvips. The first start pays 25 ms more, once, to write the files. Later starts pay for loading sharp’s library and heic-convert’s WebAssembly, as any server that imports them does. Requests are unaffected, because the packages are already loaded by then.
Compiling for another platform
Nitro traces the packages installed on the build machine. On a Mac those are
the Mac’s: @img/sharp-darwin-arm64, not the linux one a server needs. When
the compile script names a --target, the build looks for that platform’s
packages in node_modules and puts them in the binary instead. When they are
not installed, bun run compile fails and names them, rather than ship a
binary that crashes on its first import. So does bun run compile:binary
after a build that found them missing:
[rsc-kit] The binary is compiled for linux-x64 (glibc), and these native packages for it are not installed:
@img/sharp-libvips-linux-x64@1.3.4
@img/sharp-linux-x64@0.35.5
Install them for that platform, then compile again:
bun install --os=darwin --os=linux --cpu=arm64 --cpu=x64Keep your own platform in that command, because the build still loads the
packages on your machine. --target=bun-linux-x64-musl, for Alpine, asks for
the linuxmusl packages. A plain bun run build only warns, since it may
never be compiled.
Reading a stack trace
A trace from a built app names the build output, not src/:
at zz_throw_default (.output/server/_ssr/zz-throw-D9B2cblU.mjs:7:12)That file is Nitro’s copy of the chunk Vite built for the module. It is named
for the module (zz-throw is src/commands/zz-throw.ts, Counter is
Counter.tsx) plus a hash that changes every build, and it is not in the
binary’s directory or shipped with it, so the path is a label and not a file
you can open. The line and column are the chunk’s, not the source’s. Frames
from the engine and the rest of the app show rsc.mjs:<line>.
Source maps do not change this today:
- Bun ignores input source maps.
bun .output/server/commands.mjsand a binary compiled with--sourcemapboth show the chunk path, even when the chunk has a map that is correct (a hand-made one resolved to the exact source line under Node and was ignored under Bun).--sourcemapmaps the binary’s bundle back to its input files and no further. That is Bun’s limit, and rsc-kit does not work around it. - Nitro writes the maps for
_ssr/with no mappings. Vite’s own map for the chunk is correct and points atsrc/, but Nitro’s copy of the chunk has a map with a source and nothing else, sonode --enable-source-mapsshows the chunk too (checked withbuild.sourcemapand Nitro’ssourcemapboth on). This is Nitro’s, and fixing it would help only a Node server.
Native dependencies stay outside the bundle
A package with a native binary — sharp, bcrypt, better-sqlite3,
@prisma/client — cannot be rolled into a server bundle: the build succeeds
and the server cannot load its own binary. The usual ones are left external
by default, and Nitro traces each into .output/server/node_modules with its
binaries, so the deployment is still one directory. For one that is not on
the list:
rscKit({ serverExternalPackages: ['@acme/native-thing'] })A compiled binary carries them and writes them to disk on its first start. See native packages in a binary.
A polyfill runs first
A dependency that checks for a polyfill at module evaluation — tsyringe
wants Reflect.getMetadata, under @peculiar/x509, under
@simplewebauthn/server — depends on import 'reflect-metadata' running
before it, and the source has it there. A bundler does not keep that place:
it emits a chunk’s imports before its external ones, whatever the source
said, so the check ran first — survivable by luck as a directory, fatal
compiled into a binary. When the project’s graph has reflect-metadata
anywhere in it, the build loads it in a Nitro plugin, which the server’s
entry evaluates before any of the app is imported; a project without it
has nothing that checks. Nothing to add to instrumentation.ts.
A dependency’s "use client" is read
The other direction. A dependency is bundled into the server graphs — and
so has its "use client" directives read — when it declares react as a
peer dependency, which is what a React library does. One that imports React
and never says so (a generated component wrapper, a workspace package with
react under dependencies, a package whose author forgot) would be left
external, and its directive is then a string nobody reads: the server loads
it as a server module and the first useState in it fails at render, with an
error that names React and not the package.
So the build reads the direct dependencies once, and bundles any that carry
a "use client" file and plugin-rsc would not have. It says so:
[rsc-kit] bundling @acme/chat-widget: it has "use client" files but does not
declare react as a peer dependency, so its components would otherwise run
on the server.Nothing to configure; the line is there so the package’s author hears about the missing peer. The React-using dependencies under such a package — the runtime a generated wrapper calls into — are bundled with it, so there is one React for all of them: left external, that runtime would load React through Node while everything else got it through Vite, and the hooks dispatcher is null in one of the two. Only direct dependencies are read for the directive itself — a directive two levels down is the concern of the package between, which declared it.
Offline
rscKit({ offline: true }) writes a service worker into .output/public
alongside the assets, so a page someone has visited survives a reload with no
network. Off by default, and covered in Offline.
Why nitro is pinned
The generated package.json pins an exact version rather than a range:
"nitro": "3.0.260903-beta"Nitro’s own latest tag is a dated prerelease, and it sorts above the plain
3.0.0 on npm. So ^3.0.0 resolves to the older release, which builds without
complaint and then answers 404 to every route. TanStack Start pins a dated beta
for the same reason.
Assets are Nitro’s
The build writes browser assets to .output/public, and Nitro serves them from
its own root. There is no assetsDir or assetsUrl to set: both were removed,
and rscKit() refuses a config that still passes them rather than reading a
prefix and ignoring it.
The alternative was the silent version — the markup asks for the app’s prefix, Nitro answers at its own, and the page arrives unstyled and never hydrates with nothing logged anywhere.
Assets live in .output/public and are served by the same process that serves
your pages. If you want nginx or a CDN serving them instead, point it at
.output/public — that directory is the deployment.
Nothing is sent raw
A bun or node server answering the internet by itself compresses what it
sends, because nothing in front of it will. The build writes a .br and a
.gz beside every public asset larger than a kilobyte, and Nitro serves
whichever the request accepts — a 147 kB stylesheet is 18 kB on the wire.
The host gzips what it answers itself — documents, streamed pages, RSC
payloads, stored pages, api routes — for a request that accepts it, flushing
every chunk so a streamed shell still reaches the browser before the holes
fill; a stored page is compressed once and kept.
A port measured what this is worth: with every byte raw, first paint on a throttled phone was 3.8 s where 1.2 s was the baseline. That was the whole regression.
On a Worker the platform compresses and the host does nothing. Behind a
CDN or nginx that compresses, the proxy sees an already-encoded answer and
passes it through. A deployment that would rather its proxy did all of it
turns the host’s half off with compress: false on the handler, and Nitro’s
with compressPublicAssets: false in its config; Cache-Control: no-transform on an answer leaves that answer alone.
Next: Deploying →
Which entry point is whose
@rsc-kit/core/host is the engine’s front door for a host adapter — the
handler, and beside it the few functions an adapter needs when it embeds the
engine. Several of those are re-exports of app-facing modules, so an editor’s
auto-import may offer redirect or revalidate from host. They are the
same functions; an app imports them from their own entry points:
| in an app | not |
|---|---|
@rsc-kit/core/redirect |
@rsc-kit/core/host |
@rsc-kit/core/revalidate |
@rsc-kit/core/host |
Only an adapter imports host. The re-exports there are marked internal, so
they sort below the app entry in a completion list.