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
Originhost differs from the request's own host answers403. A cross-origin caller you trust goes inallowedOrigins: ["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. RaisemaxBodyBytesfor larger uploads, or passInfinityto 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.