Migrating to 4.0

Two changes reach every consumer: the runner option is required, and react-server-loader is a peer dependency you install yourself. A third — the . package entry no longer condition-splits — changes nothing in your imports.

Declare a runner

Every config passes runner: "main" | "isolated" | "edge" — the execution paradigm, i.e. where react-server executes. There is no default: a missing runner errors at config-resolve time with the three options, because a default would silently pick a topology for you. The declared runner is validated against the process condition, so runner and scripts either agree or the build says so loudly.

Pick by what your scripts already do:

Scripts run plain vite / vite build --app (no NODE_OPTIONS) — declare "isolated". A worker thread owns react-server resolution; nothing else changes.

vitePluginReactServer({
  runner: "isolated",
  // ...unchanged
})

Scripts carry NODE_OPTIONS='--conditions react-server' (the old :rsc variants) — declare "main", and put the flag on every command that loads the config (dev, build, preview): vite.config.ts and its imports resolve before plugin code runs, so the flag cannot be added retroactively.

vitePluginReactServer({
  runner: "main",
  // ...unchanged
})

A repo that deliberately runs both topologies (a dev and a dev:rsc script over one config) — pick one and delete the other script; that duality is what the flag replaces. If running both is the point (a demo, a compatibility matrix), say so explicitly by deriving from the condition:

import { getCondition } from "vite-plugin-react-server/config";

vitePluginReactServer({
  runner: getCondition() === "react-server" ? "main" : "isolated",
  // ...unchanged
})

"edge" (single isolate, React baked per environment) validates like "isolated" — no process flag — and makes the baked pair the serving artifact: build.edge: false is a config-time contradiction and a failed bake fails the build. It requires transport: "webpack" (the pair's consumer half needs the webpack module map; the esm transport has no equivalent seam yet) — esm apps use "isolated", with build.edge if they want the producer artifact. The edge runner is a production serving choice: dev runs the isolated worker shape, and the pair is what deploys.

Install react-server-loader yourself

react-server-loader moved from dependencies to peerDependencies, so one copy in your tree serves vprs and your app — the private nested copy npm used to create on the experimental train (and the overrides blocks that papered over it) is gone. Install it alongside react / react-dom.

Stable (React floor is ^19.2.8):

npm install react@^19.2.8 react-dom@^19.2.8 react-server-loader

Experimental: install all three at the exact snapshot vprs's peer range names — the floating @experimental dist-tag moves daily and drifts past the exact peer:

npm view vite-plugin-react-server peerDependencies  # the exact snapshot
npm install react@<snapshot> react-dom@<snapshot> react-server-loader@<snapshot>

If you carried an overrides block forcing react-server-loader to one copy, delete it — the peer layout makes it redundant.

Server actions guard themselves by default

The action endpoint's two guards were opt-in in 3.x and are on by default in 4.0:

  • Same-origin enforcement: a POST whose Origin host differs from the request's own host answers 403. A cross-origin caller you trust goes in allowedOrigins: ["https://app.example.com"] (same-origin stays allowed alongside the list); allowedOrigins: "any" restores the 3.x open behavior.
  • Body cap: request bodies over 1 MiB answer 413. Raise maxBodyBytes for larger uploads, or pass Infinity to restore the 3.x uncapped behavior.

The . entry is one module

The root entry no longer condition-splits: import { vitePluginReactServer } from "vite-plugin-react-server" resolves the same module under both conditions and dispatches per side at runtime. Imports are unchanged. The explicit vite-plugin-react-server/client and /server subpaths keep their behavior: opting into a side under the wrong condition still fails loudly.

Type-level: StreamPluginOptions.runner is required, so configs typed with satisfies StreamPluginOptions surface the missing field at typecheck time — the same fix as the runtime error: declare a runner.