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.
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.
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.
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.
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.
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.