Skip to content

Where it runs

A host is a Nitro preset, not a server you write.

Updated View as Markdown

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.

vite.config.tsts
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.mjs

A Worker has no start, because it is deployed rather than started:

npm run preview  # wrangler dev, on workerd
npm run deploy   # nitro deploy --prebuilt

Compiling 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/app

compile 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/c3a3844a949c748c

They 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/app

The 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 .node or .wasm file, 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=x64

Keep 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.mjs and a binary compiled with --sourcemap both 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). --sourcemap maps 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 at src/, but Nitro’s copy of the chunk has a map with a source and nothing else, so node --enable-source-maps shows the chunk too (checked with build.sourcemap and Nitro’s sourcemap both 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close