Build Output
Running NODE_OPTIONS='--conditions react-server' vite build --app produces three directories:
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/ # Hashed JS/CSS bundles
│ └── .vite/manifest.json
├── client/ # Client components built for Node
│ ├── page/
│ │ └── page.js
│ └── components/
│ └── Counter.client-CnBCzH8H.js
└── server/ # Server components (react-server condition)
├── page/
│ ├── page.js
│ ├── props.js
│ └── actions.server.js
└── components/
└── Counter.client-CnBCzH8H.js
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.
This directory is generated after the other two builds complete, using dist/server/ for server components and dist/client/ for client components.
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. The RSC payload is reused for client-side navigation — when a user clicks a link, the browser fetches the .rsc file instead of a full page reload.
Consistent Hashing
The same source file gets the same content hash across all three builds:
dist/client/components/Link.client-CnBCzH8H.js
dist/server/components/Link.client-CnBCzH8H.js
dist/static/components/Link.client-CnBCzH8H.js
This ensures module references are consistent between client and server. When the server component tree references a client component by module ID, that ID resolves correctly in both the browser (dist/static/) and Node (dist/client/).
Using the ESM Modules in a Server
The build output is designed to be consumed by any Node.js HTTP server. Here's an Express example:
// server.ts
import express from "express";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();
// Serve static files (pre-rendered HTML + assets)
app.use(express.static(join(__dirname, "dist/static")));
// Handle RSC requests
app.get("*.rsc", (req, res) => {
res.setHeader("Content-Type", "text/x-component");
res.sendFile(join(__dirname, "dist/static", req.path));
});
// Handle server actions (POST requests to module paths)
app.post("/src/*", async (req, res) => {
const modulePath = join(__dirname, "dist/server", req.path);
const mod = await import(modulePath);
const [, exportName] = req.url.split("#");
const result = await mod[exportName](...req.body);
// Stream RSC response back
res.setHeader("Content-Type", "text/x-component");
// ... stream the result
});
app.listen(3000);
For a real-world example, see the bidoof-template demo.
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, not on the edge. The moment you render HTML per
request — the flash-free dynamic-route path (createInlineFlightRenderer and
the html-worker behind it) — you need Node. vprs 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. Edge runtimes such as
Cloudflare Workers and Deno Deploy have no worker_threads, so this path does
not run there. 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 runtime RSC-payload and server-action helpers are likewise implemented on Node primitives today. A static build has no server runtime and therefore no callable surface, so this only applies once you stand up a dynamic server.
The pattern for an edge deployment today is hybrid: serve dist/static/
from the edge/CDN (instant, global, no runtime) and put any dynamic SSR or
server actions on a Node origin behind it. vprs does not currently ship an
in-process edge SSR path (react-dom/server.edge, no worker); whether to add
one is an open question, not a present feature. If first-class edge SSR is a
hard requirement, @vitejs/plugin-rsc reaches the edge in-process today (via
Vite's environment API and Cloudflare's child-environment workers) and is the
better fit.
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
Single-step (recommended)
NODE_OPTIONS='--conditions react-server' vite build --app
Builds all three environments in sequence automatically.
Multi-step (for debugging)
vite build # static
vite build --ssr # client
NODE_OPTIONS='--conditions react-server' vite build --ssr # server
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, React inlined (server condition)
This is additive — the three directories above are untouched. render.js
exports renderRouteToFlight(url) and runs in a single isolate with no
worker_threads and no runtime --conditions, for edge runtimes. Pair it with
createEdgeHandler to get a Web fetch handler. See Edge / Single-Isolate.
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
Access them in server components via process.env.