Bundler Version Compatibility Reference
This reference pins the version relationships between the major JavaScript bundlers and the Node.js runtimes and ecosystem packages they depend on: Rollup 3.x and 4.x, Vite 4 through 6, esbuild 0.19 through 0.25, and Turbopack as shipped in Next.js 13 through 15, against Node.js 18, 20, and 22. It documents the breaking changes and version conflicts that surface as engine warnings, ERR_REQUIRE_ESM failures, and peer-dependency mismatches during installs and CI. For the architectural background on why these tools couple the way they do — Vite delegating production builds to Rollup and dependency pre-bundling to esbuild — see Core Concepts of Modern Bundling, then use the matrices below to choose a known-good combination.
This problem exists because a modern bundler is not one program but a layered dependency graph that the package manager assembles at install time. When you write npm install vite@6, the resolver walks the transitive tree, picks the Rollup and esbuild builds Vite declared, checks each package’s engines.node field against the running interpreter, and reconciles every plugin’s peerDependencies against what actually landed in node_modules. Any one of those steps can produce a tree that installs cleanly on your laptop and then fails on a CI runner whose Node image is one minor behind, because the constraints are evaluated against the concrete runtime and lockfile present at that moment, not against the version numbers you typed.
Getting the combination wrong breaks in three characteristic ways. A too-old Node prints an EBADENGINE warning and then behaves unpredictably — the resolver, the native Rollup binary, or a top-level await in a config file may fail long after the warning scrolled past. A forced Rollup or esbuild override throws at config load, usually as plugin X is not a function, because a framework plugin was compiled against a plugin-API shape that no longer exists. And a peer mismatch aborts the install itself with ERESOLVE, which teams reflexively paper over with --legacy-peer-deps and thereby ship a tree the plugin author never tested. The rest of this page is the map of where each of those lands, so you can pick a combination that survives the jump from a developer machine to a locked-down CI image.
What This Page Pins and Why
Bundler compatibility is not a single number; it is the intersection of three constraints. First, the Node.js engine range a bundler declares — installing Vite 6 on Node 16 produces an EBADENGINE warning and unpredictable resolver behavior. Second, the internal coupling between tools: Vite bundles a specific Rollup major and a specific esbuild range, so you do not pick those independently — Vite pins them, and overriding via overrides/resolutions is where conflicts originate. Third, ecosystem peers: framework plugins (@vitejs/plugin-react, @vitejs/plugin-vue), PostCSS, and TypeScript each declare peer ranges that must overlap the bundler you chose.
Pinning matters because mismatches fail loudly in some cases (ERR_REQUIRE_ESM when a CJS config loads an ESM-only plugin) and silently in others (a Rollup major bump changing manualChunks semantics so chunks split differently with no error). The tables below give the known-good combinations and the specific traps at each boundary.
The silent class is the dangerous one, because there is no red text to grep for. A Rollup major bump can keep every plugin loading and every build passing while quietly changing how modules are grouped into chunks, which changes the emitted filenames and their content hashes. That reshuffles your long-term browser cache, invalidates the source-map file set your error tracker uploaded, and can move a dependency out of the vendor chunk you deliberately sized for HTTP caching. None of that shows up as a failure — the build is green — so the only defense is to treat a bundler major as a change that requires re-checking the output manifest, not just re-running the test suite. The practical rule that falls out of this: pin the bundler major in package.json ("vite": "^6.0.0", not "vite": "*"), let patch and minor float, and gate every major bump behind an explicit review of the produced assets.
Bundler to Node.js Compatibility
| Bundler | Versions | Node 18 | Node 20 | Node 22 | Notes |
|---|---|---|---|---|---|
| Vite | 4.x | Supported | Supported | Warns | Vite 4 predates Node 22; works but unsupported |
| Vite | 5.x | Supported (≥18.0) | Supported | Supported | Requires Node 18+; drops Node 16 |
| Vite | 6.x | Min 18.0 / 20.0 | Supported | Supported | Node 18.0–18.17 excluded; needs 18.18+ or 20.19+ |
| Rollup | 3.x | Supported (≥14.18) | Supported | Works | Maintenance only; superseded by 4.x |
| Rollup | 4.x | Supported (≥18.0) | Supported | Supported | Native bindings shipped per-platform; needs Node 18+ |
| esbuild | 0.19–0.21 | Supported | Supported | Works | Go binary; engine range is permissive |
| esbuild | 0.23–0.25 | Supported | Supported | Supported | Recommended current line |
| Turbopack (Next) | 13 | Supported (≥16.14) | Supported | n/a | next dev --turbo alpha; not for builds |
| Turbopack (Next) | 14 | Supported (≥18.17) | Supported | Works | Turbopack dev beta; next build still Webpack |
| Turbopack (Next) | 15 | Min 18.18 | Supported | Supported | next dev stable on Turbopack; build stabilizing |
The Node floors above are enforced by the engines.node field each package publishes. At install time npm reads that field and, if the running interpreter falls outside the declared range, emits an EBADENGINE warning; it does not abort unless you have set engine-strict=true in .npmrc. That default is a trap: the warning scrolls past in a noisy CI log, the install “succeeds”, and the failure resurfaces later as a cryptic runtime error deep inside the bundler. The Vite 6 floor is the specific gap that bites most often, because it is not a clean “Node 18 or newer” boundary. Vite 6 requires 18.18+, so the entire 18.0 through 18.17 window is excluded even though it is the same major. A CI image labelled node:18 that resolves to an early 18 patch will fail this floor while a developer on node:18.19 sees nothing wrong.
// package.json — declare the runtime you actually support (Vite 6 line)
// verified against Vite 6.0, npm 10, Node 20.19
{
"engines": {
"node": ">=18.18.0 || >=20.19.0 || >=22.12.0"
}
}
Setting engine-strict=true in a project .npmrc promotes the warning to a hard install failure, which is what you want in CI: better to fail at npm ci with a clear message than to fail three steps later inside vite build. Confirm the running version with node -v as the first line of the build job, not as an afterthought — the single most common “it works locally” incident in this space is a runner whose Node is a patch or minor behind the floor the bundler quietly requires.
Internal Coupling: What Vite Bundles
You do not select Rollup and esbuild versions for a Vite project — Vite pins them. Overriding the pinned versions is the most common self-inflicted breakage.
overrides/resolutions is the most common self-inflicted config-load failure.| Vite | Bundled Rollup | Bundled esbuild | Min Node |
|---|---|---|---|
| 4.x | Rollup 3.x | esbuild 0.18.x | 14.18+ / 16+ |
| 5.x | Rollup 4.x | esbuild 0.19.x–0.21.x | 18+ |
| 6.x | Rollup 4.x | esbuild 0.24.x–0.25.x | 18.18+ / 20.19+ |
If you force a different Rollup major through overrides, plugin API contracts (this.emitFile, renderChunk signatures, manualChunks function arguments) may not match the version @vitejs/plugin-* packages compiled against, producing plugin X is not a function at config load.
Under the hood, Vite does not shell out to a globally installed Rollup — it imports the exact Rollup build nested in its own dependency tree and re-exports its plugin container, so at runtime there is one and only one Rollup instance driving the production build. That is why overriding the version is so destructive: an overrides block that forces "rollup": "4.x" when Vite expects a different patch line replaces the copy Vite imports, and every plugin that touches Rollup’s internal PluginContext is now talking to an object whose method table has shifted. The failure is not always immediate; a plugin that only calls this.emitFile inside generateBundle will load fine and then throw at the very end of the build, after minutes of work.
The legitimate reasons to override are narrow: a published security advisory against the pinned esbuild or Rollup patch, or a confirmed upstream bug fixed in a newer patch that Vite has not yet absorbed. In both cases override to the nearest patch within the same major Vite pinned, never across a major, and remove the override the moment Vite catches up. If you find yourself reaching for an override to get a feature, the correct move is almost always to bump Vite itself, because the feature you want usually shipped alongside the Rollup or esbuild bump inside a Vite release.
// package.json — a defensible override: same major, one patch forward
// only until Vite ships the fix; verified with Vite 6.0 / Rollup 4
{
"overrides": {
"rollup": "4.24.4"
}
}
You can confirm what actually resolved — rather than what you asked for — by inspecting the installed tree. npm ls rollup esbuild prints the deduplicated versions and, crucially, whether more than one copy landed; two Rollup versions in the output is a near-certain sign that an override or a stray transitive dependency has split the tree and is a strong predictor of the plugin is not a function class of failure.
Known Breaking Changes and Conflicts
- Vite 4 to 5: Node 14/16 dropped; CJS Node API deprecated (importing
vitefrom a CommonJS file warns and is slated for removal). Thedefineofprocess.envbehavior tightened. Rollup 3 to 4 underneath changedoutput.manualChunksto also accept the{ getModuleInfo }helper consistently. - Vite 5 to 6: Node 18.0–18.17 excluded — the minimum is 18.18 (or 20.19/22.12 for newer sub-lines). Default
build.targetmoved tobaseline-widely-available. The legacy CJS API was removed entirely; ESM config or.mjs/.mtsis mandatory. - Rollup 3 to 4: Rewritten parser (SWC-based) and per-platform native binaries (
@rollup/rollup-linux-x64-gnu, etc.). The common CI failureCannot find module @rollup/rollup-linux-x64-gnustems from an npm optional-dependency bug when a lockfile is reused across platforms; fix by deletingpackage-lock.json+node_modulesand reinstalling on the target OS, or pinning the optional dep. - esbuild 0.19 to 0.25: Each minor can introduce intentional breaking changes (esbuild does not follow semver in the 0.x range). Notable:
--sources-contentdefault handling, strictertsconfig.jsonextendsresolution, andkeepNamesinteractions with class fields. Always read the esbuild changelog before bumping a minor in CI. - Turbopack / Next 13 to 15: Turbopack was dev-only and behind
--turboin 13/14; Next 15 makesnext devTurbopack the documented default, andnext build --turbois the stabilizing build path. Plugins written for the Webpacknext.config.jswebpack()hook do not run under Turbopack — they require Turbopack’sturbopack/turboconfig surface instead.
The Rollup native-binary failure in depth
The Cannot find module @rollup/rollup-linux-x64-gnu error deserves its own treatment because it is the single most reported Rollup 4 CI break, and the usual reflex — reinstalling — does not always fix it. Rollup 4 replaced its JavaScript parser with a native one compiled per platform and CPU architecture, distributed as a family of optionalDependencies like @rollup/rollup-linux-x64-gnu, @rollup/rollup-darwin-arm64, and @rollup/rollup-win32-x64-msvc. Only the binary matching the current platform is meant to install; the rest are skipped as optional. The break comes from a long-standing npm bug (npm issue 4828) where, if a package-lock.json was generated on one platform — say a macOS laptop — and then npm ci runs against it on another — a Linux runner — the lockfile records the macOS optional binary and omits the Linux one, so the Linux binary is never fetched and Rollup cannot load its parser.
The symptom is a clean install followed by an immediate crash on the first vite build or rollup -c. The root cause is a lockfile that encodes the wrong platform’s optional dependency. The durable fix is to make the lockfile platform-agnostic or regenerate it in the same environment the build runs in; the fast fix in CI is to remove the stale artifacts before installing.
# .github/workflows/build.yml — regenerate the tree on the runner's own platform
# verified with Rollup 4 / Vite 6, npm 10, ubuntu-latest
- name: Clean install for correct native binary
run: |
node -v # confirm the engine floor first
rm -rf node_modules package-lock.json
npm install # resolves the linux-x64-gnu optional dep here
npm run build
Confirm the fix by checking that the platform binary is present after install: ls node_modules/@rollup | grep linux on a Linux runner should list rollup-linux-x64-gnu. If you must keep a committed lockfile — the normal, correct choice for reproducibility — pin the matching optional dependency explicitly for the CI platform, or commit the lockfile from a build that ran on the same OS and architecture as the runner. Alpine-based images are a common variant of this trap, because they need the -musl binary (@rollup/rollup-linux-x64-musl) rather than -gnu; a lockfile from a glibc machine will fetch the wrong one.
Peer Dependency Alignment
--legacy-peer-deps.| Package | Vite 5 peer | Vite 6 peer |
|---|---|---|
| @vitejs/plugin-react | 4.x | 4.3+ |
| @vitejs/plugin-vue | 5.x | 5.2+ |
| vite-plugin-pwa | 0.20+ | 0.21+ |
| @sentry/vite-plugin | 2.x | 2.x |
TypeScript ≥5.0 is assumed for moduleResolution: "bundler", which all current Vite, Rollup, and esbuild lines expect. Mismatched plugin peers surface during install as npm ERR! ERESOLVE — resolve by upgrading the plugin to the line matching your bundler rather than forcing --legacy-peer-deps.
A peer dependency is a contract the plugin author writes: “I call into this version range of Vite’s internals, and I have not tested anything else.” When npm 7+ finds a plugin whose peerDependencies.vite range does not include the Vite you installed, it aborts with ERESOLVE rather than guessing. --legacy-peer-deps restores the pre-npm-7 behavior of ignoring peer constraints entirely, which does not make the incompatibility go away — it ships it. The plugin will load against internals it was never compiled for, and the failure moves from a clear install-time error to an obscure runtime one, typically a transform hook throwing on a shape it did not expect. The reason to upgrade the plugin instead is that the plugin author has already published a build whose peer range includes your Vite; taking it costs one version bump and removes the mismatch at the source.
The direction of the fix matters. When you bump Vite across a major, expect to bump its framework plugin in the same commit — @vitejs/plugin-react and @vitejs/plugin-vue track the Vite line closely and a lagging plugin is the usual ERESOLVE cause. When a third-party plugin has genuinely not shipped a compatible release yet, that is a signal to hold the bundler upgrade, not to force the tree; a plugin that has not been updated for the new Vite major is one you are about to run untested. Reserve --legacy-peer-deps for the rare case where you have read the plugin’s changelog, confirmed the internals it uses are unchanged, and have a test that exercises the path — and even then, prefer a scoped overrides entry over the global flag so the exception is documented in package.json rather than buried in a CI command.
Verifying a Known-Good Combination
Rather than trusting the tables from memory, encode the check. A short preflight script that runs before the build asserts the Node floor and prints the resolved bundler versions, turning a silent drift into an explicit failure with a readable message.
#!/usr/bin/env bash
# scripts/preflight.sh — assert the engine floor and surface resolved versions
# verified with Node 20.19, npm 10, Vite 6
set -euo pipefail
REQUIRED_MAJOR=20
CURRENT_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
if [ "$CURRENT_MAJOR" -lt "$REQUIRED_MAJOR" ]; then
echo "Node $CURRENT_MAJOR is below the required floor $REQUIRED_MAJOR" >&2
exit 1
fi
# fail if more than one Rollup resolved — a split tree predicts plugin errors
if [ "$(npm ls rollup --all --parseable 2>/dev/null | grep -c '/rollup$')" -gt 1 ]; then
echo "Multiple Rollup copies resolved; check overrides/resolutions" >&2
exit 1
fi
npx vite --version
npx esbuild --version
Wire this into the pretest or prebuild npm lifecycle hook so it runs automatically. The payoff is that the class of failures this page catalogues — an under-floor Node, a split Rollup tree, an unexpected esbuild minor — surfaces in one place with a message a reviewer can act on, instead of as a stack trace from deep inside a plugin.
Performance and Cache Considerations Across Versions
Version choice is not only a correctness question; it moves build times and cache behavior. esbuild’s Go binary is the fast path for dependency pre-bundling, and its startup and transform throughput improved measurably across the 0.19 to 0.25 range, so an old esbuild pinned by an old Vite is a hidden tax on cold dev-server start. Rollup 4’s native parser cut production build wall-clock time relative to Rollup 3 on large graphs, which is a reason to prefer the Vite 5/6 line for big applications even setting features aside. The counterweight is cache stability: because chunking and hashing can shift across a Rollup major, the first build after an upgrade will invalidate the browser cache for returning users and force a full re-download of the changed vendor chunks. That is a one-time cost, but schedule it deliberately — do not ship a Rollup major bump in the same release as a latency-sensitive campaign, and warm your CDN afterward so the first real user does not pay for the cache miss.
Upgrade and Migration Notes
EBADENGINE gate that blocks a same-image bump.Upgrade Node and the bundler in the same change set when crossing a major, because the engine range moves with it: going to Vite 6 on a CI image still running Node 18.16 fails the EBADENGINE gate even though Vite 5 was fine. Bump the CI image to Node 20.19+ first, confirm node -v, then bump Vite. When the source map or chunking behavior of a deployed app must stay stable across an upgrade, validate against the guidance in Source Maps and Production Debugging — a Rollup major bump can shift chunk boundaries and therefore the .map file set your error tracker expects.
Ordering is the whole trick here, and it is worth stating as a rule because reversing it produces a confusing failure. Node first, bundler second, plugins in the same commit as the bundler. If you bump the bundler before the runtime, the engine gate blocks you and the error text points at the bundler, sending you to debug the wrong layer. If you bump the bundler without its framework plugin, ERESOLVE blocks the install and again the message is about a package you did not touch. Sequencing the runtime bump as its own reviewable step — change the image tag, confirm node -v in the log, merge — means that by the time the bundler bump lands, the floor is already satisfied and the only thing left to reason about is the bundler’s own changes.
Treat the first green build after a major bump as unverified, not done. A passing test suite confirms behavior, not output shape. Diff the generated asset manifest — filenames, chunk sizes, and the set of emitted .map files — against the previous release before promoting the build, because the silent-failure class described earlier hides precisely in that gap between “tests pass” and “the bytes shipped are the bytes you expected”.
When Not to Upgrade
Not every project should chase the current bundler line, and pinning to a maintenance version is sometimes the correct engineering call rather than technical debt. A build on Vite 4 with Rollup 3 that ships reliably and whose plugins are all still maintained does not need to move for its own sake; the upgrade carries the cache-invalidation and re-verification cost described above for a benefit you may not need. Hold the current line when you are inside a release freeze, when a load-bearing plugin has no build compatible with the newer bundler major, or when your deployment target constrains Node below the newer bundler’s floor and you cannot raise it. The one exception that overrides all of these is a published security advisory against the pinned version — a CVE in esbuild or a transitive dependency is a reason to move even mid-freeze, though even then you move to the nearest patched version, not necessarily to the newest major.
Deprecations
- Vite CJS Node API: deprecated in 5, removed in 6. Migrate config and programmatic usage to ESM.
- Rollup 2.x and 3.x: maintenance only; new plugin development targets 4.x APIs.
- esbuild
--bundlewithout an explicit--format: still works but the inferred format changed across 0.x lines — set--format=esmexplicitly. - Next.js Webpack
next build(non-Turbopack): not deprecated, but Turbopack is the forward path in Next 15; new framework features land on Turbopack first.
The Vite CJS API removal is the only entry here that is a hard wall rather than a nudge, and it is worth understanding why it lands the way it does. When Vite’s Node entry became ESM-only, a vite.config.js loaded by CommonJS resolution — for example under a toolchain that still require()s it — throws ERR_REQUIRE_ESM on Vite 6 where Vite 5 only printed a deprecation warning. The fix is mechanical: rename the config to vite.config.mjs (or .mts), or add "type": "module" to package.json so .js is interpreted as ESM, and convert any require()/module.exports in the config to import/export default. Confirm it by loading the config with node --input-type=module semantics or simply running vite --version against the ESM config; if that succeeds, the programmatic path will too. The other three deprecations are directional — old code keeps working — so schedule them with normal upgrades rather than as emergencies, but do not start new work on the deprecated surface.
Related
- Core Concepts of Modern Bundling — the parent overview of how Vite, Rollup, esbuild, and Turbopack relate architecturally.
- Source Maps and Production Debugging — version-sensitive source map emit modes that change across Rollup and Vite majors.
- Understanding ESM vs CommonJS in Modern Bundlers — the module-format boundary behind the CJS API deprecations called out above.