Configuration
import { defineConfig } from "vite";
import { vitePluginReactServer } from "vite-plugin-react-server";
export default defineConfig({
plugins: vitePluginReactServer({
// Required
runner: "isolated", // "main" | "isolated" | "edge" — the
// execution paradigm; see below
moduleBase: "src",
Page: "src/page.tsx", // string or (url: string) => string
build: { pages: ["/"] },
// Optional — file-based routing (replaces Page/props/build.pages, see below)
routes: { dir: "routes" }, // scans src/routes/** — see docs/routing.md
// Optional — component resolution
props: "src/props.ts", // string or (url: string) => string
Html: "src/Html.tsx", // string — HTML shell component
Root: "src/Root.tsx", // string — root wrapper component
pageExportName: "Page", // named export to use from Page file
propsExportName: "props", // named export to use from props file
clientEntry: "src/client.tsx", // optional — see "Client entry" below
// Optional — direct component references (react-server condition only)
components: {
Page: MyPage,
Html: MyHtml,
Root: MyRoot,
},
// Optional — URL handling
moduleBasePath: "", // second arg to renderToPipeableStream
moduleBaseURL: "/", // URL prefix for modules (default: VITE_BASE_URL || "/")
publicOrigin: "", // static replacement for location.origin
// Optional — CSS
css: {
inlineCss: true, // inline small CSS files (default: true)
inlineThreshold: 4096, // size threshold in bytes
inlinePatterns: [], // RegExp[] — always inline these
linkPatterns: [], // RegExp[] — always link these
},
// Optional — build
build: {
pages: ["/"],
dir: "dist",
client: "client",
server: "server",
static: "static",
hash: "hash",
preserveModulesRoot: false, // keep src/ in output paths
renderMode: "parallel", // "parallel" | "sequential"
batchSize: 8, // pages per batch in parallel mode
rscOutputPath: "index.rsc",
htmlOutputPath: "index.html",
// How the flight payload rides inside the HTML: "blob" (alias true)
// buffers it into each prerendered index.html — static/CDN targets;
// "stream" interleaves it into per-request document renders as they
// stream — dynamic/edge targets; false fetches .rsc instead.
// "stream" is a per-request shape: transport "webpack" freezes its
// prerendered snapshots with the blob form instead, and the esm SSG
// pass leaves prerendered pages un-inlined (they fetch .rsc).
// Build-time, per-target choice. See docs/build-output.md.
inlineFlight: false,
// Optional — single-isolate edge bundle (additive; ON by default).
// `boolean | { outDir?, minify?, transport? }`. See docs/edge.md.
// edge: false — opt out
// edge: { minify: false } — keep on, tune a default
// transport: "esm" (default, import-at-request-time client refs) or
// "webpack" (baked client manifest — the self-contained deploy model);
// defaults from the top-level `transport` option when set.
edge: true,
},
// Optional — workers
htmlWorkerPath: "./custom-html-worker.js",
rscWorkerPath: "./custom-rsc-worker.js",
rscTimeout: 5000,
htmlTimeout: 15000,
htmlWorkerStartupTimeout: 5000,
rscWorkerStartupTimeout: 5000,
// Optional — dev
dev: {
useRscWorker: false, // use worker in dev mode (default: false)
},
// Optional — observability
verbose: true,
onMetrics: (metrics) => console.log(metrics),
onEvent: (event) => console.log(event),
}),
});
File-based routing (routes)
routes turns on the file-based router (v3+): the file tree under the routes
directory becomes the URL tree, and vprs derives Page, props,
routePatterns and the prerender worklist from it — you don't restate them.
routes: {
dir: "routes", // relative to moduleBase; omit to scan moduleBase itself
staticPaths: { // vprs's getStaticPaths, per dynamic route
"/greet/$name": () => [{ name: "ada" }, { name: "grace" }],
},
},
It also accepts an already-built router table — a fileRouter() result or a
hand-rolled equivalent. Explicit Page / props / routePatterns /
build.pages still win over what the router derives. Full guide:
Routing.
Component Resolution
The plugin resolves components in this order:
components.*— direct references (highest priority, react-server only)- Path strings/functions —
Page,Html,Root,props - Plugin defaults — fallback
Path-based (recommended)
Works in both dev modes. Supports HMR and per-route components.
Page: (url) => `src/pages${url}page.tsx`,
props: (url) => `src/pages${url}props.ts`,
Html: "src/Html.tsx",
Direct references
Faster builds, no file resolution. Only works when the main thread has the react-server condition.
import { MyHtml } from "./src/Html.js";
components: { Html: MyHtml },
Client entry
For the conventional setup — an index.html with <script type="module" src="/src/client.tsx"> (or similar) — you do not need to set clientEntry. vprs leaves that file to Vite's own entry-point discovery and won't add it as a duplicate input, even if the file carries a "use client" directive.
Set clientEntry only when the client entry isn't referenced from index.html and needs to be picked up another way.
Runner
runner declares the execution paradigm — where react-server executes — and
is required. The declared runner is validated against the process condition at
config-resolve time, so a mismatch is a loud error rather than a silently
different topology.
| Runner | Scripts carry | RSC runs on | Trade |
|---|---|---|---|
"isolated" | no flag | Worker thread | No launch flags, better isolation |
"main" | NODE_OPTIONS='--conditions react-server' | Main thread | A bit faster, better stack traces, React usable in vite.config.ts |
"edge" | no flag | Single isolate (baked pair) | Portability; requires transport: "webpack", the pair is the serving artifact (build.edge: false errors, a failed bake fails the build), and dev runs the isolated worker shape — the pair serves prod |
"main" and "isolated" produce identical output. Under "main", the RSC
worker is skipped in dev — Vite's environment runner handles HMR directly.
Build Scripts
The scripts match the declared runner. With runner: "isolated":
{
"scripts": {
"dev": "vite",
"build": "vite build --app",
"preview": "vite preview"
}
}
With runner: "main", every command states the flag that topology requires
(vite.config.ts and its imports resolve before plugin code runs, so the
flag cannot be added retroactively):
{
"scripts": {
"dev": "NODE_OPTIONS='--conditions react-server' vite",
"build": "NODE_OPTIONS='--conditions react-server' vite build --app",
"preview": "NODE_OPTIONS='--conditions react-server' vite preview"
}
}
Third-party "use client" packages
Libraries like Chakra UI, MUI, Mantine, react-aria, and framer-motion ship per-file "use client" directives in their compiled output. vprs picks them up automatically: the server build converts each directive module into a client reference instead of executing it under the react-server condition, so server components can import the package's components directly — provided the package is also reachable from the client graph. The client build only hosts package modules that some first-party "use client" module imports (commonly the provider wrapper); a reference with no client-side importer dangles at SSG render (ERR_MODULE_NOT_FOUND). See the README's "Third-party client-component packages" section for the usage rules.
Detection runs once at config-time via vitefu.crawlFrameworkPkgs: any package with react in peerDependencies is added to the bundle's noExternal list and has its directives preserved through esbuild's pre-bundle (optimizeDeps.exclude); client-graph-reachable "use client" modules are emitted as their own client chunks so the html-worker can resolve client references at SSG render time.
| Option | Type | Default | Purpose |
|---|---|---|---|
clientPackages | readonly string[] | [] | Manual additions, merged with auto-detected. Use for packages that don't list react in peerDeps but should be treated as client-packages anyway. |
excludeClientPackages | readonly string[] | [] | Skip auto-detected packages. Common case: dev-only Storybook deps that aren't part of the prod import graph. |
vitePluginReactServer({
// ... other options ...
clientPackages: ["@my-org/internal-ui"],
excludeClientPackages: [
"@storybook/react",
"@storybook/react-vite",
"@storybook/react-dom-shim",
],
});
If detection fails (missing lockfile, monorepo edge), the build continues with whatever's in clientPackages — and emits a warning if verbose: true.
preserveModulesRoot
Controls whether moduleBase (e.g. src/) appears in output paths:
| Value | Input | Output |
|---|---|---|
false (default) | src/page/home.tsx | dist/client/page/home.js |
true | src/page/home.tsx | dist/client/src/page/home.js |
App Mode (--app)
vite build --app is the whole story — the plugin supplies its own
builder.buildApp, which builds every environment in order and then runs the
post-build steps (deferred static generation, the edge bake, the
transport-webpack freeze). Do not define a builder.buildApp of your own:
a config-level hook overrides the plugin's and silently skips those post-build
steps.
export default defineConfig({
plugins: [vitePluginReactServer(options)],
});
Transport
The deploy's RSC flight flavor. Default "esm" — module references are URLs
the browser imports directly; the best DX for static sites with no
per-request rendering.
vitePluginReactServer({
runner: "isolated",
transport: "webpack",
});
With "webpack" the whole deploy carries one flavor, and every artifact
renders once, in that flavor: the option defaults the edge bake to the
webpack pair, and the enumerated routes' index.html/index.rsc snapshots
render through that pair — the same producer + consumer a deployed
handler runs — instead of through the esm SSG pass. One deploy may then
serve any route from the CDN or the per-request handler interchangeably; the
browser client, the inline flight, and the fetched .rsc payloads all
agree. Build events and metrics are unchanged: the pair render owns the same
build.ssg.*/file.write events and ssg-render summary the esm pass
emits.
The dev server follows the option too: with "webpack" it renders
webpack-flavored flight and the browser decodes it live — dev and production
run the same transport, no parity caveats.
Do not hand-mix flavors instead: an esm-hydrated document cannot decode a
webpack .rsc (and vice versa), so serving both to one client breaks
navigation between them. Per-surface splits are fine only when a single
surface actually serves a given deploy — which is exactly what this option
exists to make unnecessary.
The option is a plain value, so per-mode policy is ordinary JavaScript:
vitePluginReactServer({
runner: "isolated",
transport: process.env.NODE_ENV === "production" ? "webpack" : "esm",
});
Dev and production are separate sessions — each is internally coherent — so choosing a flavor per mode is safe in a way per-route mixing within one deploy never is.
The vprs-starter is the
living example: one transport: "webpack" line, and its Vercel deploy serves
/about from CDN snapshots while / renders per request — with client-side
navigation across that boundary, which is precisely what one flavor buys.
Metric Watcher
Opt-in build output: wire metricWatcher into onMetrics and the build prints
per-route lines, batch overviews and cold-start attribution instead of the raw
summaries. See API Reference → Metric Watcher
for the output format and options.
import { metricWatcher } from "vite-plugin-react-server/metrics";
vitePluginReactServer({
runner: "isolated",
onMetrics: metricWatcher(),
});