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:

  1. components.* — direct references (highest priority, react-server only)
  2. Path strings/functions — Page, Html, Root, props
  3. Plugin defaults — fallback

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(),
});