npm run build writes one directory, and it is the deployment:
.output/server the server Nitro built, and the engine it calls
.output/public hashed assets and the pages frozen at build timenpm run build
npm run start # node or bun .output/server/index.mjsnpx vite preview runs the same build through Vite’s preview server, which is
what Nitro suggests at the end of a build. Both serve the real thing; start
is what a deployment runs.
Anywhere that runs the runtime
.output/ is self-contained. Copy it and start it — a container, a VPS, a
process manager, a platform that runs a Node or Bun process. There is nothing to
register and no platform API to satisfy, and no node_modules: the
dependencies are in the bundle.
FROM oven/bun:1 AS build
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
FROM oven/bun:1
WORKDIR /app
COPY --from=build /app/.output ./.output
CMD ["bun", ".output/server/index.mjs"]The second stage carries .output and nothing else. On the docs application
that is 1.0 MB against 104 MB of node_modules.
A release step, and processes that are not the server
The image above starts a server because that is what the binary does with no
arguments. With src/commands it does something else with one:
docker run --rm myapp migrate # before the new version takes traffic
docker run --rm myapp work # a worker, beside the web processesAny platform that can run a command in the image does the same — a Kubernetes Job, a release command, a process type — and nothing in the app names which. See Commands.
Stopping gracefully
Nothing to write. A rollout sends the old process SIGTERM (or SIGINT), and
the built server stops the way it should:
- It stops taking connections and gives in-flight requests
SERVER_SHUTDOWN_TIMEOUTseconds to finish (5 by default). - It calls
shutdown()frominstrumentation.tsif you export one, for whatever you opened: a database pool, a queue client, a telemetry exporter. It getsRSC_SHUTDOWN_TIMEOUTseconds (10); one that hangs or throws is reported and does not hold the process. - It exits with code 0.
An app with nothing open exits as soon as the drain finishes.
Two things still undo it quietly:
- Another
SIGTERMhandler that exits. A logger or SDK that callsprocess.exit()on a signal runs first if it was imported first, and the drain never starts. In-flight requests, webhooks included, are dropped on every rollout, and the process exits 0, so nothing looks wrong. A logger should flush onexit, not handle the signal. - A grace period shorter than the drain. On Kubernetes,
terminationGracePeriodSecondshas to coverSERVER_SHUTDOWN_TIMEOUT, thenshutdown(), then anypreStopsleep, or theSIGKILLarrives first.
Or a platform, without a Dockerfile
Change the preset and Nitro produces what that platform expects — a Worker and
its wrangler.json, a Vercel function, a Netlify handler. See
Where it runs.
Bindings on Workers
A D1 database or an R2 bucket named in wrangler.jsonc is read from
cloudflare:workers, the runtime’s own module — the bundle leaves it to the
runtime, like bun:sqlite. vite dev under Bun or Node has no such module,
so import it where the code runs on Workers, behind a check for the runtime:
const onWorkers = typeof navigator !== 'undefined' && navigator.userAgent === 'Cloudflare-Workers'
export async function db() {
if (onWorkers) {
const { env } = await import('cloudflare:workers')
return env.DB // the D1 binding, as wrangler.jsonc names it
}
const { Database } = await import('bun:sqlite')
return new Database('data/local.sqlite')
}The build renders pages through the same module, so a page that reads a table with no request in it is frozen from the local database, and only the pages that read the url or the request reach D1 at request time.
Two things that fail quietly
Do not set NODE_ENV when starting the server. The build bakes its mode
into the bundle, so a server started with nothing set is production because it
was built that way. Setting it at start time is a second source of truth and
the one that can disagree — and when it disagrees the failure is silent: every
page renders, and none of them hydrate.
A server started with nothing set gives its own process the mode it was built
in, before the app loads, so process.env.NODE_ENV is production at run time
too. That is what a typed env.ts built from a spread of process.env reads,
and before 0.30.36 it read nothing and fell to its schema’s
.default('development'). It also means rsc-kit’s own production checks run:
an app that uses refreshOn must set RSC_SIGNING_SECRET, or its live pages
answer 500.
The check is React’s debug rows in the payload:
curl -s https://your-app.example.com/ | grep -c ':D{'0 on a correct production build. Anything else means a development bundle
reached the client.
Serve .output/public at the root. Nitro does this itself, so this only
matters behind a CDN: point it at that directory and let the hashed filenames do
the caching — they are content-addressed, so they can be cached forever.
What the document sends ahead
Every document answers with a Link header naming what it needs first —
its stylesheet, the client entry, and the fonts its layout preloads:
Link: </assets/index-D_il52MF.css>; rel=preload; as=style,
</assets/index-C-u4Ll0r.js>; rel=preload; as=script; crossorigin,
</assets/geist-latin-wght-normal-BgDaEnEv.woff2>; rel=preload; as=font; crossoriginCloudflare turns that into a 103 Early Hints sent before the origin
answers and cached at the edge, so those three download while the HTML is
still on its way; any other CDN passes it through, and a browser reads it
the moment the headers land, before the parser reaches the <head>. The
critical set only, never the chunks: a hint promotes what it names, and
naming every module demotes the document behind them. Stored documents
carry it too — a stored document’s own head names its fonts; a rendered
document’s head is not known until after its headers have gone, so it
names the stylesheet and the entry. Nothing to configure, and nothing it
replaces: the stylesheet still blocks paint, and inlining critical CSS
stacks on top of it.
The entry is a preload with as=script, not a modulepreload, because
Cloudflare builds its 103 from preload and preconnect alone and would
leave a modulepreload out. crossorigin gives the same fetch a module
script makes, so the browser uses the preloaded file and does not download
it twice. The document’s own <link rel="modulepreload"> is unchanged, so
the module is still compiled early.
Streams and idle timeouts
An events() route and the refreshOn
stream hold a connection open and are quiet most of the time. Anything in
front that closes a quiet connection - the server’s idle timeout, a proxy’s
read timeout - would cut them, so each sends a keepalive line every eight
seconds: under Bun’s default ten, and well under nginx’s sixty and
Cloudflare’s hundred.
If something in front closes idle connections sooner, set the keepalive below it:
RSC_STREAM_KEEPALIVE_MS=4000 # under a 5-second idle timeoutToo long a keepalive does not lose anything - the browser reconnects, and a
tab catches up on whatever moved - but every open tab is cut and reopens each
time the timeout passes. Both streams are sent with Cache-Control: no-store, no-transform and are never compressed, so nothing in front buffers them.
Secrets
rsc-kit reads up to three. Each is its own key for its own job - never reuse one as another, so one leaking does not hand out the rest.
| Variable | What it protects | Who needs it | Read by |
|---|---|---|---|
RSC_HOST_CALL_SECRET |
calls between the renderer and a Go or Laravel backend | an app with a backend - required | the renderer and the backend |
RSC_SIGNING_SECRET |
the names a page hands a tab for refreshOn |
an app that uses refreshOn - required in production |
the renderer |
RSC_ACTION_ENCRYPTION_KEY |
values a server action sends through the browser, across a deploy | optional | the renderer, from the build |
bun create rsc-kit and rsc-kit init generate the first two into .env for
an app with a backend, and php artisan rsc:install does the same for
Laravel. That is development. For production, generate each once and keep it
where you keep secrets - your platform’s environment settings, not the repo:
openssl rand -base64 32or, without openssl:
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
php -r "echo base64_encode(random_bytes(32)), PHP_EOL;"Three rules apply to all of them:
- The same value on every instance. Each is checked by a process that may not be the one that issued it: a tab served by one renderer, a call landing on another backend replica. A value per instance fails for some requests and not others.
- At runtime, from the environment. The built server reads them when it
starts, so one build deploys anywhere.
RSC_ACTION_ENCRYPTION_KEYis the exception: set it at build time too. - Never in git. The scaffold’s
.gitignorealready leaves out.env.
Rotating one is a deploy, and each says so differently:
RSC_HOST_CALL_SECRET- the renderer and the backend must change together. Between the two, everyrpc()is refused, so deploy both at once.RSC_SIGNING_SECRET- pages already open hold names signed with the old key. Set the old value asRSC_SIGNING_SECRET_PREVIOUSfor the deploy that rotates it: it is accepted when checking a signature and never used to make one, so open tabs keep refreshing while every page rendered after the rotation carries the new key. Remove it a day later. Without it, those tabs stop refreshing until reloaded; nothing else breaks.RSC_ACTION_ENCRYPTION_KEY- an action called from a page loaded before the change fails, the same window as a deploy without the key pinned.
What the build produces
A deploy that plans before building - a binary or a folder, which script to run - asks first:
bunx rsc-kit info --jsonIt loads the app’s own Vite config the way a build does, so an output
computed in the config, or RSC_OUTPUT=export in the environment, resolves
exactly as the build will resolve it. Then every build writes the same
answer to .output/rsc-kit.json as it finishes - the confirmation, from the
code that decided; refuse a deploy where the two disagree:
{ "version": "0.30.33", "output": "server", "compile": "compile:binary", "binary": "dist/app" }{ "version": "0.29.6", "output": "export", "dir": "dist" }output: "server"- run the server.compileandbinaryare there when the app’spackage.jsonhas a compile step: run the scriptcompilenames after the build, and the file its--outfilenames is the single binary to ship. It iscompile:binarywhen the app has one, which compiles the output the build just wrote. Otherwise it iscompile, which usually runsvite buildagain first; the build says so, and a scaffolded app has both.output: "export"-diris the static site; serve it as files.
One field is only in the build’s file, because only the build knows it:
commands, the app’s commands. Compare the rest with
info.
Pages an edge may serve from its cache
rsc-kit implements the edge contract: one marker and two endpoints that an edge uses the same way for every framework that implements it. next-bun-compile implements it too. The edge serves a static page whole and a PPR page’s shell straight from its cache, and has the origin render only a shell’s holes, onto the same response. Serving shells from a CDN covers what that buys. What rsc-kit puts in it:
.output/edge.jsonis written by every server build, with empty lists when there is nothing to list. An export writes none.shellsare the pages the build report marks◐, as patterns.staticare the pages it marks○, each at the path it was rendered at. Both leave out every page with middleware above it (amiddleware.ts, or the host’sexport const middleware), since middleware decides per request who may see the page, and every page bound from the request’s host (app/[domain]/), since a path cannot say which host it is.staticalso leaves out a frozen redirect and a page a listed shell pattern matches.- A root
middleware.tsempties both lists, since it is above every page, and the build names the middleware that kept each page out. Headers that are the same on every page belong inrscKit({ headers })instead (see security headers): they keep the page listed, and/_edge/pagehands them to the edge as the page’sheaders. - Past 200,
shellsdrops the deepest andstatickeeps the shallowest, and the build says which. A page left out is served by the origin, which answers it correctly. buildcovers the client bundle and the bytes of every stored page, shell and postponed state, so a release that changes only what the server rendered is a new build, and an edge drops what it cached before it. It is not the version a browser tab sees: a server-only change does not make open tabs reload.- A static page’s payload is copied to
/assets/_rsc/<hash>.rsc, servedimmutablelike the rest of/assets/, and named in the document, so the page hydrates without asking the origin for anything. EDGE_TOKENswitches both endpoints on. The platform sets it; without it, everything under/_edge/is a 404.- The resume’s body is ignored. rsc-kit hands out
"state": ""and reads the postponed state from its own build, so it never parses what a caller wrote.
Do not work the list out from the files in .output/server/rsc-static/. A
guarded page has a shell on disk too, _team_ cannot say whether it was a
parameter or a folder of that name, and the folder’s layout is not a contract.
A build carries no backend address and no secret. RSC_BACKEND,
RSC_HOST_CALL_SECRET and RSC_SIGNING_SECRET are read when the server
runs, so one build runs against whichever backend its deployment names, and
rotating a secret is a restart, not a rebuild.
Is it ready
GET /_rsc/health says whether this renderer can serve, for a platform
deciding when to route to it - which an open port alone does not say:
{ "ok": true, "version": "08f805d3", "backend": "ok" }- 200 - up.
backendis"ok"when the backend answered,"none"for an app without one. - 503 - the backend did not answer within 2 seconds: unreachable, or refusing the secret.
Why it is not ready names the app’s insides, so the reason and backend go
only to a probe that sends the host-call secret, as x-rsc-host-secret.
Anyone else gets { "ok": true } or { "ok": false } and the status - which
is all a router needs. Not keyed on localhost: behind a reverse proxy on the
same machine, every visitor is localhost.
The backend check asks the route guards with none to run - every adapter
passes an empty list - so it runs no app code and reads no data, yet it
proves the address, the network, the shared secret and the adapter. Never
cached; HEAD answers with the status alone.
Pages across a deploy
A page from the previous build asks for a payload from the new one, whose
client components its own manifest cannot load. Every document carries
which build it is (<meta name="rsc-kit:build">, an id derived from the
client entry), the client sends it as X-RSC-Version on every payload
request, and a server on another build answers 409 with where to go;
the client loads the document from the current build, once. A payload that
still names a client reference the page lacks — a CDN answering from cache,
say — reloads the document the same way a missing chunk does. Nothing to
configure, and nothing to set: the id is the client’s, so a deploy that
changed only the server leaves every open page alone. There is no
override — a version set anywhere else would disagree with what the
document says, and a client’s honest claim would be refused on every
navigation.
Server actions across a deploy
A server action can close over server-side values, and React encrypts those before sending them to the browser so the page cannot read them. The process that decrypts them on the way back has to hold the same key.
By default that key is generated at build time and baked in. Every instance running the same build agrees, so the only exposure is the deploy itself: a browser sitting on a page from the old build calls an action on the new one, and the key has changed underneath it. The call fails.
For most apps that window is seconds and nobody notices. If yours is long enough to care about — a slow rollout, long-lived pages, an app people leave open — pin the key:
# once, kept wherever you keep secrets
openssl rand -base64 32RSC_ACTION_ENCRYPTION_KEY=<that value>Set it at build time and the build stops baking its own; the value is read from the environment when the server runs, so the same artifact deploys anywhere.
Frozen pages and the routes that own them
.output/public holds whole frozen pages and PPR shells alongside the assets.
They are read through the prerendered reader, which is a function rather than a
directory precisely so a runtime with no filesystem — a Worker — can supply them
from a binding instead.
A route that declares middleware is never cached publicly: it is sent as
private, no-store, because middleware runs per visitor. If something in front
of your app also owns the response — an auth proxy re-issuing a session on
pass-through — give the paths it covers a middleware.ts so this host knows
they are covered. See serving shells from a CDN.
Rebuild on deploy
Cached responses carry a build version, so a deploy invalidates them.
Build, then start — never build under a running server. The server loads
its rsc and ssr services lazily, on the first request that needs each, and
a vite build that is rewriting .output/ at that moment hands it a file
that is half-written or briefly missing. The runtime caches that failed
import — Bun does, and so does Node’s ESM loader — so the route answers 500
with an ENOENT for a file that now exists, and keeps doing so until the
process restarts. In development use vite (the dev server), which owns its
own rebuilds; in production build into a fresh directory or stop the server
first, and start it against a finished .output/.
Ship .output/ from the same commit as the code that serves it. A server
running one build against another’s frozen pages is the one combination nothing
checks for you.
HTTP/2 in front
Every client component is its own chunk, so a page loads only the code it renders — and a page with many of them makes several small requests. Over HTTP/2 or HTTP/3 those share one connection and cost nothing extra; over HTTP/1.1 a browser opens at most six connections per origin and the rest queue.
Behind Cloudflare there is nothing to do: HTTP/2 and HTTP/3 are on by default,
for proxied sites and Workers alike. The case to check is a Bun or Node server
exposed to the internet directly, which speaks HTTP/1.1 — put anything that
speaks HTTP/2 in front of it (a CDN, an ingress controller, Caddy, nginx), which
is how almost every deployment runs anyway. curl -sI --http2 https://your.site
answers HTTP/2 when it is right.