Build Output
This page is the contract between the build and everything that serves it. Every way of deploying a vprs app (a static host, a server you own, an edge function) is a consumer of the output described here, not a separate build mode. The deploy recipes all read from this page.
Running vite build --app produces three directories (the command follows
your declared runner: runner: "main" requires the
NODE_OPTIONS='--conditions react-server' prefix, runner: "isolated" runs
plain — the output is identical either way):
dist/
├── static/ # Browser-ready output — deploy this
│ ├── index.html # Pre-rendered HTML
│ ├── index.rsc # RSC payload (for client navigation)
│ ├── about/
│ │ ├── index.html
│ │ └── index.rsc
│ ├── assets/ # Everything the build generates, mirroring src/
│ │ ├── components/
│ │ │ └── Counter.client-CnBCzH8H.js
│ │ └── page/
│ │ ├── theme-D4k2xQ1a.css
│ │ ├── hero-B7mN3pZq.png
│ │ └── fonts/body-Hs8Lw2Rt.woff2
│ └── .vite/manifest.json
├── client/ # Client components built for Node
│ └── assets/components/
│ └── Counter.client-CnBCzH8H.js
└── server/ # Server components (react-server condition)
├── page/
│ ├── page.js
│ ├── props.js
│ └── actions.server.js
└── assets/components/
└── Counter.client-CnBCzH8H.js
Where generated files go: build.assetsDir
build.assetsDir (default "assets", the same meaning as Vite's own
build.assetsDir) is where every file the browser build generates goes:
JS chunks, stylesheets, images, fonts, anything a module imports with ?url
or a stylesheet references with url(). One rule names them all:
<assetsDir>/<source directory, moduleBase stripped>/<name>-<hash>.<ext>
So src/components/Link.client.tsx's chunk lands at
assets/components/Link.client-<hash>.js, src/page/theme.css and the
src/page/hero.png it references next to each other under assets/page/,
and src/page/fonts/body.woff2 under assets/page/fonts/. The pre-rendered
index.html and index.rsc files and copies of public/ keep their paths
at the root: they are not generated names and are revalidated, not cached as
immutable.
Set build.assetsDir: "" for the tsc-like tree with no prefix, where the
output mirrors src/ directly:
assetsDir: "assets" (default) assetsDir: ""
dist/static/ dist/static/
├── index.html ├── index.html
└── assets/ ├── components/
├── components/ │ └── Link.client-<hash>.js
│ └── Link.client-<hash>.js └── page/
└── page/ ├── theme-<hash>.css
├── theme-<hash>.css ├── hero-<hash>.png
├── hero-<hash>.png └── fonts/body-<hash>.woff2
└── fonts/body-<hash>.woff2
Because the browser file name is also the client-reference id (see
Consistent Hashing), dist/client/ and the client
references in dist/server/ carry the same prefix; server-only modules keep
their paths. A user-supplied rollupOptions.output.assetFileNames (or
entryFileNames / chunkFileNames) replaces the rule for that kind of file.
Upgrading: before this rule, stylesheets lived under assets/ while JS
chunks, images and fonts were written to the root of dist/static/. Every
generated file now lives under assets/, so one host rule that caches
/assets/* as immutable covers JS, CSS, images and fonts alike. Nothing in
your source changes; a deploy script that copied dist/static/*.js or
dist/static/*.png by path needs the new location.
dist/client/ holds only client components and their dependencies — the
page server component lives in dist/server/ alone (the renderer imports it
from there; dist/client/ exists to resolve client references). With the
file router, page modules mirror the routes directory instead:
dist/server/routes/page.js, dist/server/routes/about/page.js, and so on.
Understanding the Three Builds
A note on naming
React and Vite use the words "client" and "server" differently, and this matters for understanding the build output:
- React splits code by boundary: "client components" run on both client and server (they handle interactivity + SSR/SSG), "server components" run on the server only.
- Vite splits code by target: "client" means browser-ready bundles, "ssr" means Node.js-importable modules.
A React client component gets built twice — once as a browser bundle (Vite's "client" environment → dist/static/), and once as a Node-importable module (Vite's "ssr" environment → dist/client/). The Node version exists because during static HTML generation (or runtime SSR), the renderer must import() client components to produce HTML on the server.
Here's how Vite's build environments map to output directories:
| Vite environment | Output | What it contains | Why it exists |
|---|---|---|---|
client | dist/static/ | Hashed browser bundles, pre-rendered HTML, RSC payloads | Deploy to any static host. This is the final product. |
ssr | dist/client/ | Client components as ESM with bare specifier imports (react, react-dom) | The static renderer (and runtime SSR) needs to import() client components using Node module resolution. Browser bundles use hashed URLs — Node can't import those. |
server | dist/server/ | Server components, props, server actions (react-server condition) | Server-only code. Props functions, server actions with registerServerReference, and server components with client references replaced by registerClientReference. |
dist/static/ — deploy this
A self-contained static site. Every route in build.pages gets an index.html (full page) and index.rsc (RSC payload for client-side navigation). Deploy to GitHub Pages, Netlify, S3, or any static host.
The directory itself is Vite's ordinary browser build: hashed JS/CSS bundles plus Vite's own index.html, a build input (it keys the client bootstrap in the manifest and is the dev entry) that Vite emits into the output on every build. Static generation then enriches the directory after the other two builds complete: it renders each route through dist/server/ and dist/client/ and writes the prerendered index.html + index.rsc in, overwriting the Vite template when a page claims /. When no page claims /, the bare template is pruned instead of shipped. It is a build intermediate, and left in place it would shadow a per-request route on hosts that serve files before rewrites.
dist/client/ — client components for Node
ESM modules with bare specifier imports, built for Node.js. During static generation, the renderer imports these to resolve client component references into actual React elements for HTML rendering.
Without this build, the renderer would only have the server component tree with opaque client references — it couldn't produce complete HTML.
dist/server/ — server components
ESM modules built under the react-server condition. Server actions are transformed with registerServerReference. Client component imports are replaced with registerClientReference stubs that tell the RSC serializer "this is a client boundary, here's its module ID."
How Static Generation Works
Understanding the build pipeline helps explain why all three directories are needed:
1. client build → dist/static/ (browser bundles, index.html shell)
2. ssr build → dist/client/ (client components for Node)
3. server build → dist/server/ (server components, props)
└─ writeBundle hook triggers static generation:
a. import props from dist/server/props.js
b. import Page from dist/server/page.js
c. render RSC stream (server components → serialized React tree)
d. import client components from dist/client/ to resolve references
e. render HTML stream (RSC stream → full HTML document)
f. write index.html + index.rsc to dist/static/
The dual-stream architecture (step c + e) is why the plugin produces both .html and .rsc files. build.inlineFlight chooses how the flight payload rides inside the HTML instead of being fetched:
"blob"(aliastrue) — each prerendered page's initial flight is buffered and inlined into itsindex.htmlas one non-executable<script id="vprs-flight">, so the first paint hydrates in place with no/index.rscround-trip. The file is buffered by construction (SSG writes a file anyway) and the client reads a single script — the fit for static/CDN targets."stream"— on per-request document renders (the edge handler's document mode), the flight is interleaved into the HTML as it streams: executable push-script chunks between React's flushes, no full-document buffering, TTFB is the shell flush — the fit for dynamic/edge targets. Prerendered pages have no streamed form, and what they carry instead depends on the transport: undertransport: "webpack"the SSG freeze emits its snapshots with the blob form (a static file has no "as it renders" to interleave into), while under the esm SSG pass the post-SSG inliner skips and prerendered routes fetchindex.rsc.false(default) — no inlining; the client always fetches the flight.
The inlined payload answers the request the document was served for, and only the first flight fetch after load can take it. That fetch takes it when it asks for the same route (base path, trailing slash, index.rsc and hash do not matter; a query only counts when the fetch names one). A client whose first fetch asks for a different URL than the document's gets that URL from the network, and the payload is dropped.
In every mode the .rsc files still serve later client-side navigations — when a user clicks a link, the browser fetches the next route's .rsc instead of a full page reload. startClient is the client entry that boots all of this in the browser.
Consistent Hashing
With default hashing enabled, every browser file the bundler emits is named by Rollup's content hash: JavaScript chunks (virtual and dependency chunks included), stylesheets, and the images and fonts they reference. A stylesheet's hash covers what it inlines from @import and the hashed names of the files it url()s, so editing either renames it. A host can cache these hashed URLs as immutable. Files copied from public/, HTML, RSC payloads, and custom output names without a content hash do not have this guarantee; do not apply an immutable rule to every *.js file. The client build runs first and records what it emitted in dist/static/.vite/manifest.json. The ssr and server builds read that manifest: a client component's file takes the same name in all three outputs, and that name is the module ID baked into the flight payload:
dist/static/assets/components/Link.client-CnBCzH8H.js
dist/client/assets/components/Link.client-CnBCzH8H.js
dist/server/assets/components/Link.client-CnBCzH8H.js
So the ID resolves in the browser (dist/static/) and in Node (dist/client/). Stylesheets and other assets follow the same manifest: the ssr and server builds name each one as the client build did, so the <link> tags and flight payloads they write point at the files in dist/static/. Building the ssr or server environment without the client build's manifest is an error; vite build --app builds them in order.
Redeploying with cached clients
Content-hashed chunk URLs prevent a returning browser from reusing old JavaScript for a new file. Keep HTML and fixed-path RSC payloads revalidated so an ordinary navigation discovers the current chunk names. Public files such as WASM glue and binaries need their own versioned paths or revalidation policy.
An already-open tab still runs its old module graph. Hashing alone does not keep old lazy chunks available after deployment or make old and new RSC runtimes compatible. Retain prior release assets for active clients, or detect a build change before client navigation and perform a normal document navigation. Bound any chunk-error reload retries, handle offline failures separately, and preserve unsaved work before reloading. A query parameter on a fixed WASM path can bust a cache, but cannot retrieve an older glue/binary pair once the host replaces it.
CSS in the Output
Stylesheets ride the same pipeline as the JS: the client build emits every
imported CSS file under dist/static/assets/, in its source directory and
named by the hash of its bytes, with the images and fonts it url()s beside
it (see build.assetsDir). Static
generation then collects, per page, the CSS its chunks import and passes it
to your Html and Root components as two maps of ready-to-render tag
props: cssFiles (per-page) and globalCss (site-wide). Each entry is one
of:
- Inlined —
{ as: "style", type: "text/css", id, children }, the file's content shipped inside the document. A file is inlined when it is at or undercss.inlineThreshold(default 4096 bytes);inlineThreshold: 0inlines everything,css.inlineCss: falsedisables inlining, andcss.inlinePatterns/css.linkPatternsoverride per file. - Linked —
{ as: "link", rel: "stylesheet", id, href, precedence: "high" }, a react-dom hoistable pointing at the hashed asset.
In dev the same props are collected from Vite's module graph on each request (full HMR); at build time they are collected once from the bundle. Rendering them is the document's job — see CSS Handling.
Using the ESM Modules in a Server
The build output is designed to be consumed by a Node.js HTTP server — but not
by hand-rolling one. createRequestHandler serves the static output with the
MIME types and traversal guard a file server has to get right, and dispatches
"use server" actions through the sealed production reference gate (an
allowlist baked from the build — never import() a module named by the
request):
// server.ts
import { createServer } from "node:http";
import {
createRequestHandler,
toNodeListener,
} from "vite-plugin-react-server/request-handler";
import * as bundle from "./dist/server-edge/render.js";
const app = createRequestHandler({
staticDir: "dist/static", // prerendered HTML, .rsc, assets
action: bundle.handleRouteAction, // sealed action gate, baked at build
});
createServer(toNodeListener(app)).listen(3000);
For the complete pattern — including per-request rendering of dynamic routes —
see the bidoof-template
demo's start.tsx and the
vprs-starter's
server/handler.mjs.
Where it runs: static anywhere, dynamic on Node
The three build outputs do not all target the same runtime, so it is worth being precise about what deploys where.
dist/static/ runs anywhere. It is just files — HTML, .rsc payloads,
hashed JS/CSS. Serve it from any static host, CDN, or edge network (GitHub
Pages, Netlify, S3/CloudFront, Cloudflare Pages). There is no runtime
requirement. For a fully static site this is the entire deployment.
Dynamic SSR runs on Node, in one of two shapes.
The default dynamic path (createInlineFlightRenderer and the html-worker
behind it) renders the react-dom HTML in a worker thread that runs in the
opposite React condition from your main thread (server components resolve
under --conditions react-server; react-dom renders the client half without
it). That worker is node:worker_threads, and the streams it speaks are
node:stream / MessagePort, so this shape needs a full Node process. The
cross-condition worker is also what lets the single-condition ESM transport
render both halves at all — see Workers and
How vprs compares.
The single-isolate shape is the edge bundle (build.edge, on by
default): a baked dist/server-edge/render.js in which server React is inlined
at build time, so the render needs no worker_threads and no
--conditions flag — one process, one Web fetch handler, flash-free streaming
SSR included. Which runtimes it reaches is a property of the transport
(build.edge.transport): the default esm transport resolves client chunks off
disk, so it runs on Node-compatible hosts (Node servers, Bun, Vercel/Netlify
Node functions); the webpack transport additionally bakes a consumer bundle
(dist/server-edge/consumer.js — client React plus every client module behind
a closed registry), and that pair resolves no modules at request time, so it
also runs on filesystem-less runtimes (Cloudflare Workers, Deno Deploy) via
vite-plugin-react-server/edge/web. See Edge / Single-Isolate.
A static build has no server runtime and therefore no callable surface, so all of this only applies once you stand up a dynamic server.
The pattern for an edge-network deployment is hybrid: serve dist/static/
from the edge/CDN (instant, global, no runtime) and put dynamic SSR or server
actions on a function behind it — worker-based, single-isolate on Node, or the
webpack pair on a Worker, your pick. One constraint on mixing: snapshots and
dynamic routes must carry the same flight flavor. The top-level
transport option guarantees that: with
transport: "webpack" the snapshots themselves render through the baked
pair, so any route may be served from CDN snapshot or per-request alike.
Only when the flavor is overridden at the edge level alone
(build.edge.transport: "webpack" without the top-level option) do
esm-encoded snapshots sit next to webpack-encoded dynamic routes; in that
split, serve every route through the edge bundles rather than the page
snapshots.
Stream Types
Headless RSC Stream (index.rsc)
Used for client-side page navigation. Contains serialized React components and CSS metadata. Smaller than full HTML — only updates what changed.
Full HTML Stream (index.html)
Complete HTML document with <html>, <head>, <body>. Used for initial page load and static hosting.
Both streams include detailed stack traces in development mode.
Build Modes
One command
vite build --app
Builds all three environments in sequence, runs static generation, and bakes
the edge bundle. The plugin supplies Vite's builder config, so plain
vite build performs the same full app build; --app (Vite's alias for
builder: {}) is explicit rather than required. There is no
per-environment CLI decomposition: vite build --ssr also resolves to the
full app build, not a partial one.
The process flag follows the declared runner: runner: "main" requires the
NODE_OPTIONS='--conditions react-server' prefix (the main thread renders
RSC directly instead of a worker; a bit faster, better stack traces), and
runner: "isolated" rejects it (the rsc-worker owns react-server
resolution). Same output either way.
Suspense in prerendered pages
The static HTML is the FINAL markup: prerendering renders each route through
react-dom/static, so a Suspense boundary that suspended during the build
ships its resolved content at its position — no fallback templates, no hidden
segments, no inline completion scripts. The file is the page, with or without
JavaScript, under any Content-Security-Policy. Per-request renders (the edge
document path, the worker per-request path, dev) keep streaming: progressive
flushes are what time-to-first-byte and inline-flight interleaving are made
of, and a live connection is where they mean something.
Parallel Rendering
For sites with many pages:
build: {
pages: ["/", "/about", ...hundredsOfPages],
renderMode: "parallel", // default
batchSize: 8, // pages per batch
}
Use renderMode: "sequential" for debugging or low-memory environments.
dist/server-edge/ (single-isolate edge bundle)
build.edge is on by default (pass build.edge: false to opt out), so the
build also emits:
dist/
└── server-edge/
├── render.js # baked Flight producer, server React inlined
└── consumer.js # webpack transport only: flight → HTML half, client React inlined
This is additive — the three directories above are untouched. render.js
exports renderRouteToDocument(url) (the full flash-free document pair the
handlers drive), renderRouteToFlight(url) (the headless .rsc producer),
handleRouteAction (the baked sealed action gate), and its own baked wiring
(bootstrapModules, clientManifest, flightTransport, …) — so
createEdgeRequestHandler(bundle) needs nothing else on Node. With
build.edge.transport: "webpack", consumer.js exports renderFlightToHtml
with every client module compiled in; passing it makes the pair
filesystem-free. See Edge / Single-Isolate.
Turning outputs off
The build has one optional artifact: the edge bundle. Everything else is either the deployment itself or an input the static generation step needs.
dist/server-edge/: passbuild.edge: false. The bake is on by default; a fully static deployment never calls it, so switching it off drops the artifact and the bundling pass that produces it.dist/client/anddist/server/cannot be skipped. They are not alternative deployments but the modules static generation imports to renderdist/static/(see the pipeline above). Keep them out of the deploy instead: uploaddist/static/only..rscpayloads ride along with every prerendered page. They are what client-side navigation fetches, so there is no knob to suppress them.build.inlineFlight: "blob"(defaultfalse) additionally inlines each route's flight into itsindex.html; it adds to the snapshot rather than replacing the.rscfiles.build.edge.minify: falseemits the edge bundle readable for inspection instead of minified; the artifact is otherwise identical.- Vendored packages are self-describing ESM. When client-package chunks
are vendored under
dist/**/node_modules/<pkg>/, each vendored dir carries its own{"type":"module"}package.json. This matters for serverless deploys that ship only build output (includeFiles: dist/**): without it, the nearest parent package.json governs, the function scope defaults to CommonJS, named exports vanish and SSR degrades to a shell — while the same build works locally where the project root'stype: moduleapplies. Node 22.12+ detects ESM from syntax and masks the difference; the stamped package.json is what makes the chunks unambiguous everywhere.
Environment Variables
The plugin sets these automatically if not provided:
VITE_MODE— build modeVITE_DEV/VITE_PROD— boolean flagsVITE_SSR— true during SSR buildsVITE_PUBLIC_ORIGIN— base URL for assetsVITE_BASE_URL— application base URL. Mirrored from Vite's resolvedbase(or themoduleBaseURLoption); an explicitly exportedVITE_BASE_URLoutranks both, as the deploy-time override.
Access them in server components via process.env.