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" (alias true) — each prerendered page's initial flight is buffered and inlined into its index.html as one non-executable <script id="vprs-flight">, so the first paint hydrates in place with no /index.rsc round-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: under transport: "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 fetch index.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 under css.inlineThreshold (default 4096 bytes); inlineThreshold: 0 inlines everything, css.inlineCss: false disables inlining, and css.inlinePatterns / css.linkPatterns override 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/: pass build.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/ and dist/server/ cannot be skipped. They are not alternative deployments but the modules static generation imports to render dist/static/ (see the pipeline above). Keep them out of the deploy instead: upload dist/static/ only.
  • .rsc payloads ride along with every prerendered page. They are what client-side navigation fetches, so there is no knob to suppress them. build.inlineFlight: "blob" (default false) additionally inlines each route's flight into its index.html; it adds to the snapshot rather than replacing the .rsc files.
  • build.edge.minify: false emits 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's type: module applies. 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 mode
  • VITE_DEV / VITE_PROD — boolean flags
  • VITE_SSR — true during SSR builds
  • VITE_PUBLIC_ORIGIN — base URL for assets
  • VITE_BASE_URL — application base URL. Mirrored from Vite's resolved base (or the moduleBaseURL option); an explicitly exported VITE_BASE_URL outranks both, as the deploy-time override.

Access them in server components via process.env.