esbuild & Turbopack Version Compatibility Reference
This reference pins the version relationships across the esbuild and Turbopack toolchains: which esbuild releases run on which Node versions, which Turbopack flag and config shape ships in each Next.js major, and how Turborepo 1.x and 2.x differ. It exists because the most expensive failures in this stack are not bugs but mismatches — an esbuild context() call against a pre-0.17 API, a next.config still using experimental.turbo after the key was promoted, or a turbo.json using pipeline under Turborepo 2.x. For the workflows these versions power, see esbuild & Turbopack Workflows; use this page to decide exactly which versions to install before you write a line of config.
The reason a version reference is worth maintaining at all is that these three tools sit at the bottom of the build pipeline, where a mismatch does not announce itself. A wrong Vite version fails loudly at vite --version; a wrong esbuild minor inside a transitive dependency shows up only as a subtly different bundle, a resolution that used to succeed now throwing, or a watch loop that never rebuilds because the script called an API method that no longer exists. Because esbuild has no 1.0, semver intuition is actively misleading here: the jump from 0.16 to 0.17 removed a public API, and the jump from 0.24 to 0.25 changed default behavior, yet both look like routine patch-level bumps to a lockfile diff or a Dependabot PR. The cost of that illusion is paid at the worst time — in CI, on a green-looking branch, after the person who wrote the build script has moved on.
The failure mode this page is built to prevent is environment divergence. A monorepo runs the same build in at least three places: a developer’s machine, a CI runner, and whatever produces the deployed artifact. If those three resolve different esbuild minors, run the dev server under different Next.js majors, or hash tasks under different Turborepo config schemas, they will produce different output while every package.json still reads “^” the same range. Pinning exact versions and knowing which pairings are compatible is not pedantry; it is the precondition for a build cache being sound, because a cache is only ever as trustworthy as the assumption that identical inputs were processed by identical tools.
What This Page Pins and Why
Three independent tools share this section but version on their own cadence. esbuild ships frequently with occasional behavior changes inside the 0.x range — there is no 1.0, so a minor like 0.17 or 0.25 can carry a breaking change a semver-trained eye would miss. Turbopack does not version independently at all; it is embedded in Next.js, so “which Turbopack” is really “which Next.js,” and the dev-flag and config-key names changed as it moved from alpha to stable. Turborepo versions normally, and its 1.x → 2.x jump renamed the central turbo.json key. Pinning all three together is the only way to keep a monorepo’s local, CI, and deploy environments hashing and building identically — the same determinism concern that drives remote caching.
The practical consequence of three cadences is that you cannot upgrade one tool “to be safe” without reasoning about the other two. esbuild rarely appears as a direct dependency; it arrives underneath Vite, tsup, or a dozen bundler plugins, each pinning its own range, so a single npm install can silently land two esbuild minors in one lockfile if the ranges do not overlap. Turbopack’s version is not a number you choose independently at all — it is decided the moment you pick a Next.js line, which means a Next.js upgrade is always simultaneously a Turbopack upgrade whether or not you intended one. Turborepo is the only member of the trio that behaves like a normal semver dependency, and even there the meaningful breakage is not the version number but the turbo.json schema it expects. The correct mental model is not “three version numbers” but “three schemas and one Node floor that must all agree at once.”
To make the agreement enforceable rather than aspirational, treat the versions as pinned facts in the repository, not floating ranges. Use exact versions in the root package.json, an engines.node field that names the floor the highest of these three tools demands, and a committed lockfile that CI installs with npm ci (or the frozen-lockfile equivalent) rather than npm install. The floor is always the maximum, not the minimum: if esbuild 0.20 wants Node 18 and Next 15 wants Node 18.18, the repository floor is 18.18, because the lower of the two would still fail on whichever tool needs more.
engines.node alone documents the floor but does not enforce it; by default npm only warns when the running Node falls below the declared range. Two extra guards close that gap. Set engine-strict=true in an .npmrc so an install under the wrong Node aborts instead of warning, and add an explicit runtime assertion in the build entry point so the failure is legible in CI logs rather than buried in an install warning that scrolled past. A three-line check pays for itself the first time a runner image silently pins an older Node:
// scripts/check-node.js — run as the first step of the build. Node floor 18.18.
const [major, minor] = process.versions.node.split('.').map(Number);
const ok = major > 18 || (major === 18 && minor >= 18);
if (!ok) {
console.error(`Node ${process.versions.node} is below the 18.18 floor for Next 15 + esbuild 0.20+`);
process.exit(1);
}
Wire that as a prebuild script and every environment — laptop, runner, deploy image — fails identically and early when its Node drifts below the floor, instead of producing a subtly different bundle that only diverges once it ships.
esbuild ↔ Node Compatibility
esbuild’s Go binary is largely Node-agnostic for the build itself, but the JavaScript API wrapper, the context() incremental API, and the watch/serve features assume a modern Node. The table below maps the practical floor.
It helps to understand why the Node floor exists at all when the actual compiler is a Go binary. The npm package ships a small JavaScript wrapper that spawns the platform-specific esbuild binary as a long-lived child process and speaks a length-prefixed binary protocol to it over stdio. Everything you call from JavaScript — build(), transform(), context() — is serialized, sent to the Go process, and the result is streamed back. The Node floor therefore has almost nothing to do with what the compiler can do and everything to do with the JavaScript features the wrapper itself uses: newer wrapper releases assume APIs and syntax that only exist in Node 18+. This is exactly why the trap in the diagram above is so easy to hit — the binary would happily run under Node 14, but the wrapper refuses to load, so the failure surfaces as a module-load error before a single file is compiled.
| esbuild | Node floor | Context/watch API | Notable behavior |
|---|---|---|---|
| 0.17.x | Node 12+ | New context() API introduced; old incremental/rebuild/watch() on build() removed |
The breaking re-architecture — build({ incremental: true }) no longer exists. |
| 0.18.x | Node 12+ | context() stable |
Minor option cleanups; safe upgrade from 0.17. |
| 0.19.x | Node 12+ | context() stable |
Common modern baseline; widely embedded by Vite 5. |
| 0.20.x | Node 18+ | context() stable |
Drops older Node in the published wrapper; align CI Node. |
| 0.21.x | Node 18+ | context() stable |
Incremental refinements; no API breaks. |
| 0.23.x | Node 18+ | context() stable |
Continued option additions. |
| 0.25.x | Node 18+ | context() stable |
Default-tightening release (see deprecations); current baseline for this section. |
The single most disruptive line is 0.17: any code written against esbuild 0.16 that called build({ incremental: true, watch: {...} }) must be rewritten to const ctx = await esbuild.context({...}); await ctx.watch();. The watch-mode workflow built on the modern API is covered in Using esbuild context watch mode for incremental rebuilds.
The reason 0.17 was a re-architecture rather than a rename is that the old model coupled incrementality to a single build() call: you passed incremental: true, got back a result object with a rebuild() method, and had to keep that object alive to reuse the in-memory state. The context() model separates the two concerns. A context owns the build configuration and all the cached parse and resolve state, and it exposes rebuild(), watch(), serve(), and dispose() as methods on that shared state. This is what lets watch mode and the dev server share one warm cache instead of rebuilding the world on each trigger, and it is why the migration cannot be a mechanical find-and-replace — the lifecycle changed, not just the option names. Concretely, the modern shape looks like this:
// esbuild >= 0.17 — the context lifecycle that replaced build({ incremental, watch })
const esbuild = require('esbuild'); // esbuild 0.25.x
const ctx = await esbuild.context({
entryPoints: ['src/index.ts'],
bundle: true,
outfile: 'dist/bundle.js',
});
await ctx.watch(); // re-runs on file changes, reusing warm state
await ctx.serve({ port: 8000 }); // optional dev server over the same context
// on shutdown, release the child process and its cache:
process.on('SIGINT', async () => { await ctx.dispose(); process.exit(0); });
Forgetting the dispose() call is the most common leak after this migration: because the context holds a live child process, a long-running dev tool that creates contexts without disposing them will accumulate esbuild processes until the machine runs out of file descriptors. Confirm a clean shutdown by watching the process list — after dispose() resolves, no orphaned esbuild binary should remain.
Detecting two esbuild minors in one lockfile
Because esbuild is almost always a transitive dependency, the most common real-world version bug is not “we are on the wrong esbuild” but “we are on two esbuilds at once.” Vite pins one range, tsup pins another, a Storybook builder pins a third, and if those ranges do not overlap, npm installs multiple copies. That is usually harmless for correctness because each consumer resolves its own copy, but it doubles the download, defeats binary caching, and — when a plugin reaches for a peer esbuild via require('esbuild') rather than its own dependency — can pair a plugin compiled against one API shape with a binary from a different minor. Audit for it directly rather than assuming a single version:
# List every resolved esbuild version in the tree. npm 10 / esbuild 0.25.
npm ls esbuild --all 2>/dev/null | grep -oE 'esbuild@[0-9.]+' | sort -u
If that prints more than one line, decide deliberately which minor should win and pin it with an overrides block in the root package.json so every consumer resolves the same binary:
// package.json — force a single esbuild across all transitive consumers. esbuild 0.25.x
{
"overrides": {
"esbuild": "0.25.0"
}
}
The trade-off is real: an override can force a plugin onto an esbuild it was not tested against, so pin to the highest minor any consumer requires rather than an arbitrary one, and re-run that consumer’s own tests after collapsing the tree. The symptom that sends you here is a plugin throwing The esbuild JS API and the esbuild binary are not the same version, which is esbuild’s own guard against exactly this mismatch — the wrapper and the child binary disagree, and it refuses to proceed rather than emit a corrupt bundle.
esbuild 0.25 default changes
The 0.25 line is the current baseline for this section, and it is easy to under-estimate because it breaks nothing at install time. Instead it changes defaults — the option values esbuild assumes when you do not set them explicitly. Historically these included stricter tsconfig.json path handling and tighter resolution fallbacks that previously masked genuinely missing files. The failure mode is insidious: your build still succeeds, but the emitted bundle differs, or a build that should have failed on a bad import now does. The only reliable way to catch a default change is to compare the --metafile output before and after the upgrade; a diff in the module graph or output sizes is the signal that a default you were relying on has moved. Never treat a 0.x default-tightening release as a silent passthrough.
The metafile is the right instrument here because it is a complete, machine-readable record of what the build actually did: every input file, every import edge, and every output chunk with its byte size. A default change that alters resolution or tree-shaking necessarily shows up as a different set of inputs or a different output size, even when the exit code stays zero. Capture the metafile on both sides of the upgrade and diff them structurally rather than eyeballing bundle sizes:
# Compare the module graph across an esbuild upgrade. esbuild 0.25.x, Node 18.18+.
git stash # go back to the pre-upgrade tree
npx esbuild src/index.ts --bundle --metafile=before.json --outfile=/dev/null
git stash pop # restore the upgraded tree
npx esbuild src/index.ts --bundle --metafile=after.json --outfile=/dev/null
# normalize each metafile into a stable, sorted view, then diff the two:
jq -S '.inputs|keys' before.json > before-inputs.txt
jq -S '.inputs|keys' after.json > after-inputs.txt
jq -S '.outputs|map_values(.bytes)' before.json > before-sizes.txt
jq -S '.outputs|map_values(.bytes)' after.json > after-sizes.txt
# any output from either diff means a default moved the graph or the emitted sizes:
diff before-inputs.txt after-inputs.txt
diff before-sizes.txt after-sizes.txt
An empty diff is genuine evidence the upgrade was a no-op for your inputs; a non-empty diff tells you precisely which files entered or left the graph so you can decide whether the new default is correct for your project or needs to be pinned back with an explicit option. This is far more trustworthy than trusting the release notes to enumerate every default that moved, because the notes describe the general change while the diff describes your build.
Turbopack ↔ Next.js ↔ Node Compatibility
Turbopack ships inside Next.js. The dev flag and config key evolved with maturity; production next build --turbopack only stabilized in the Next 15 line.
| Next.js | Turbopack stage | Dev flag | Config key | Node floor |
|---|---|---|---|---|
| 13.x | alpha | next dev --turbo |
experimental.turbo |
Node 16+ |
| 14.x | beta (dev) | next dev --turbo |
experimental.turbo |
Node 18+ |
| 15.0–15.2 | stable dev, beta build | next dev --turbopack |
experimental.turbo (deprecated) → turbopack |
Node 18.18+ |
| 15.3+ | stable dev, --turbopack build maturing |
next dev --turbopack / next build --turbopack |
turbopack (top-level) |
Node 18.18+ / 20+ recommended |
Two renames bite here. The flag changed from --turbo (Next 13/14) to --turbopack (Next 15); scripts that still call next dev --turbo on Next 15 hit an unknown-flag error. The config key moved from experimental.turbo to a top-level turbopack in Next 15 — experimental.turbo still works with a deprecation warning during the 15 line but should be migrated.
The two renames fail in opposite ways, which is worth internalizing because it decides how urgently each must be fixed. The flag rename fails hard and immediately: next dev --turbo on Next 15 aborts with an unknown-argument error, so a stale dev script simply will not start the server, and you find out the moment you run it. The config-key rename fails soft: experimental.turbo is still read during the entire Next 15 line, only emitting a deprecation warning, so a build stays green while quietly relying on a key scheduled for removal. That asymmetry is a trap in reverse — the loud failure gets fixed in minutes, while the silent one lingers in a config file until a future major removes the key and the loaders stop being applied without any error at all. Migrate the config key on the same commit as the flag, even though only the flag forces your hand, so the two never drift out of sync between your package.json scripts and your next.config.
A subtler point: production next build --turbopack is a different maturity level from next dev --turbopack. Dev-server Turbopack stabilized earlier in the Next 15 line; the production build path continued maturing through 15.3+ and should be adopted deliberately, with a fallback to the webpack build (next build without the flag) kept available until you have confirmed byte-for-byte parity on your own routes. Do not assume that a dev server running cleanly on Turbopack means the production build will — they are separate code paths with separate stability guarantees.
The reason “which Turbopack” collapses into “which Next.js” is that Turbopack is not published as a standalone package you can pin. It ships as a native binary bundled inside the next package and is selected only by the dev/build flag; there is no turbopack entry in your lockfile to fix independently. That is why a Next.js upgrade is unavoidably a Turbopack upgrade, and why you cannot hold Turbopack back a version while moving Next forward the way you can with an ordinary transitive dependency. It also means the only lever you have over Turbopack’s behavior is the turbopack config key plus whatever the Next major exposes — there is no separate Turbopack config file, and the rules you write are interpreted by whichever Turbopack the chosen Next.js embeds.
That coupling matters for the rules shape specifically. Turbopack’s rules accept webpack-style loaders by name, but the set of loaders it can run and the exact match semantics have shifted across the 15 line, so a loader chain that worked under experimental.turbo in an earlier 15 patch is not guaranteed to behave identically once promoted to the top-level turbopack key in a later one. Treat a loader rule as something to re-verify on each Next minor, not a stable contract. The confirmation is cheap: build one route that exercises the loader (an SVG import for @svgr/webpack, say) and diff the emitted module against the webpack build’s output — if they diverge, the loader is being applied differently and you have found it before your users did.
// next.config.js — Next 13/14 (Turbopack alpha/beta). Old shape.
/** @type {import('next').NextConfig} */
module.exports = {
experimental: {
turbo: {
rules: { '*.svg': { loaders: ['@svgr/webpack'], as: '*.js' } },
},
},
};
// next.config.js — Next 15+ (Turbopack stable dev). Promoted top-level key.
/** @type {import('next').NextConfig} */
module.exports = {
turbopack: {
rules: { '*.svg': { loaders: ['@svgr/webpack'], as: '*.js' } },
},
};
For the incremental-compilation behavior these versions expose, see Turbopack Incremental Compilation.
Turborepo 1.x ↔ 2.x
Turborepo’s major jump renamed the central config key and tightened environment-variable handling, which directly affects cache hashing.
| Turborepo | Config key | Env handling | Node floor | Migration |
|---|---|---|---|---|
| 1.x | pipeline |
Loose; env/globalEnv optional, more implicit inclusion |
Node 14+ | n/a |
| 2.x | tasks |
Stricter; declare env/globalEnv/globalPassThroughEnv explicitly |
Node 18+ | npx @turbo/codemod migrate |
// turbo.json — Turborepo 1.x. The `pipeline` key.
{
"pipeline": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] }
}
}
// turbo.json — Turborepo 2.x. `pipeline` renamed to `tasks`.
{
"$schema": "https://turborepo.com/schema.json",
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] }
}
}
The 2.x env-handling change is the subtler hazard: a task that implicitly picked up an env var under 1.x may now miss it from its hash unless you declare it under env/globalEnv, producing stale hits. Run the codemod, then audit declarations — the mechanics are detailed in Remote Caching and Distributed Build Coordination.
To see why the env change causes stale hits rather than loud failures, trace how Turborepo builds a cache key. For each task it hashes the task’s input files, its resolved dependency graph, the relevant slice of turbo.json, and the values of the environment variables it considers inputs. Under 1.x the last of those was generous — the tool inferred a broad set of variables — so a task that read process.env.API_URL at build time would usually see that value folded into its hash even if you never named it. Under 2.x the inference is deliberately narrowed: you declare exactly which variables are inputs, and anything undeclared is excluded from the hash entirely. The danger is not that the build breaks; it is that it succeeds against a hash that no longer reflects a value that genuinely changes the output. Build with API_URL=staging, then rebuild with API_URL=production and an undeclared variable, and 2.x returns the cached staging artifact because, as far as the hash is concerned, nothing changed. The fix is mechanical once you know to look: enumerate every environment variable read anywhere in a task’s build and place it under env (per-task) or globalEnv (repo-wide), reserving globalPassThroughEnv for variables that must reach the process but must not affect the hash. Confirm the audit with turbo build --dry=json, which prints the resolved inputs and hashed environment for each task so you can verify nothing load-bearing was omitted.
Here is the fuller 2.x shape once the env declarations are in place:
// turbo.json — Turborepo 2.x with explicit env declarations
{
"$schema": "https://turborepo.com/schema.json",
"globalEnv": ["CI"],
"globalPassThroughEnv": ["AWS_SECRET_ACCESS_KEY"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"env": ["API_URL", "NODE_ENV"]
}
}
}
In that example API_URL and NODE_ENV are inputs — change either and the build task re-runs — while AWS_SECRET_ACCESS_KEY reaches the process for uploads without being part of the hash, so rotating the secret does not needlessly bust every cache entry.
Known Breaking Changes & Deprecations
- esbuild 0.17 — context API replaces
incremental/watch(). Removebuild({ incremental, watch }); adoptesbuild.context()plusctx.watch()/ctx.rebuild()/ctx.serve(). This is the hard cutover dividing pre- and post-0.17 build scripts. - esbuild 0.25 — tightened defaults. The 0.25 line carried default-behavior changes (stricter resolution and option defaults that previously had looser fallbacks). Re-run your build after upgrading and compare
--metafileoutput; do not assume a silent passthrough. - esbuild 0.20 — published wrapper Node floor raised. The JS API wrapper expects Node 18+; older CI Node images can fail to load the package even though the Go binary would run.
- Next.js 15 —
--turbo→--turbopack. The dev flag was renamed. Update everydevscript; the old flag errors on Next 15. - Next.js 15 —
experimental.turbo→turbopack. The config namespace was promoted to a top-level key.experimental.turbowarns during the 15 line and should be migrated before it is removed. - Turborepo 2.x —
pipeline→tasks. Theturbo.jsonkey was renamed and env handling became stricter. Apply@turbo/codemod migrateand declare env vars explicitly.
Upgrade & Migration Notes
Upgrade these tools one axis at a time and re-measure, because they interact through the cache. A safe order: bump Node first and confirm the existing build is green; upgrade esbuild and diff --metafile output for unexpected tree-shaking or resolution changes; upgrade Next.js and migrate the Turbopack flag/key in the same commit so dev scripts and next.config move together; upgrade Turborepo last and run the codemod so the tasks rename and env declarations land atomically. After any of these, invalidate caches once (rm -rf .next node_modules/.cache/turbo) so the first post-upgrade run rebuilds from clean inputs rather than replaying an artifact produced by the prior toolchain — recall that the Node version is not in the Turborepo hash by default, so a Node bump alone will not bust stale artifacts.
The reason the one-axis-at-a-time discipline is not merely tidy but necessary is that a simultaneous multi-tool bump makes a regression un-bisectable. If you move Node, esbuild, Next.js, and Turborepo in one commit and the bundle changes, you cannot tell which tool moved it without unwinding all four; if you move one and diff, the cause is unambiguous. The cache turns this from a preference into a rule, because Turborepo will happily serve you an artifact built by the previous toolchain and present it as the new build’s output. That is the specific danger the final cache-clear guards against: without it, the first green run after an upgrade may be green only because it never actually ran under the new tools.
CI Integration and Version Enforcement
A compatibility reference is only load-bearing if CI refuses to drift from it. The goal is that a pull request which changes any of the four pinned facts — Node, esbuild, Next.js, Turborepo — either fails loudly or forces an explicit decision, rather than merging a range that resolves differently next week. Three cheap gates cover the common drift.
First, install with a frozen lockfile so CI can never silently resolve a newer minor than the one committed. npm ci fails if package-lock.json and package.json disagree, which is exactly the signal you want: it means someone bumped a range without regenerating the lock, and the fix is to commit the lock rather than let CI paper over it. Second, assert the Node version before the build runs, using the same check-node.js guard shown earlier, so a runner image that quietly ships a different Node fails at a named step instead of producing a divergent artifact. Third, on any branch that touches a build tool, run the --metafile diff and the turbo build --dry=json audit as required checks, so an esbuild default change or a dropped Turborepo env declaration surfaces as a failing check on the PR that introduced it.
# .github/workflows/build.yml — enforce the pinned toolchain. Node 18.18, npm 10.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc' # single source of truth for the Node floor
- run: node scripts/check-node.js # hard-fail if the runner drifted
- run: npm ci # frozen lockfile — no silent minor bumps
- run: npx turbo build --dry=json > dry.json # inspect hashed inputs in review
- run: npm run build
Committing an .nvmrc and pointing setup-node at it removes the last place Node can diverge: the CI config and the developer’s version manager now read the same file, so “works on my machine” and “works in CI” are backed by the same number rather than two independently maintained ones. None of these gates is expensive, and together they convert the tables above from documentation into something the merge queue actually enforces.
Related
- esbuild & Turbopack Workflows — the overview these version pairings underpin.
- Turbopack Incremental Compilation — the engine whose flag and config shape changed across Next.js majors above.
- Remote Caching and Distributed Build Coordination — where the Turborepo 1.x/2.x env-handling change directly affects cache keys.
- esbuild API and CLI for Rapid Builds — the API surface reshaped by the esbuild 0.17 context cutover.