How to Configure ESM and CJS Interop in Vite
Vite’s ESM-first architecture frequently throws module-resolution errors when consuming a legacy CommonJS package, because the dev server and the production build resolve formats through two different bundlers. This guide gives the exact configuration to fix interop conflicts without sacrificing dev-server speed or production tree-shaking. For the underlying condition-matching model, read Understanding ESM vs CommonJS in Modern Bundlers before applying the overrides below.
The root of the problem is that ESM and CommonJS have incompatible loading semantics. CommonJS resolves require() synchronously and hands back a single module.exports object whose shape is only known at runtime, after the module body has executed. ESM resolves import statically, before any code runs, by reading a fixed list of named bindings that must be present in the module’s source text. When Vite has to make a CJS module look like an ESM module, it has to synthesize that static binding list from a format that never promised to have one — and any place the synthesis guesses wrong surfaces as a resolution or runtime error.
Vite never asks you to pick one format globally. Instead it runs your source through native browser ESM in dev and a full Rollup bundle in production, and it inserts a different CJS-to-ESM shim at each stage. That is why a dependency can import cleanly in vite dev, then fail vite build with a completely different error, or pass both and then throw ERR_REQUIRE_ESM only under --ssr. The three fixes below are not interchangeable: each one targets exactly one of those stages, and applying the wrong one leaves the real failure untouched. The rest of this guide walks the failure to its stage, then names the single override that stage understands.
Problem scope and reproducible setup
The errors below all stem from Vite’s split pipeline: the dev server serves native browser ESM with esbuild pre-bundling, while vite build uses Rollup. A CJS dependency that lacks a clean exports map, or that uses dynamic require(), fails to transform somewhere along that path. Reproduce with a minimal project:
Three properties of a dependency decide whether it will need help. First, its declared format: a package.json with "type": "module" and an exports map that points ESM consumers at a real .mjs entry rarely needs any override, whereas a package whose main entry is a bare module.exports = object almost always does. Second, whether its exports are statically analyzable: a file that assigns each export as a top-level exports.foo = ... is readable by the lexer Vite uses, while one that builds its export object in a loop or behind a conditional is not. Third, whether it pulls in Node built-ins like fs or path — those are fine in dev and SSR but will break a browser build unless the dependency is meant to run server-side only. Keep these three axes in mind while reading the errors; each override below is really a response to one of them.
# Vite 5.x / 6.x, Node 20+
npm create vite@latest interop-repro -- --template vanilla-ts
cd interop-repro && npm i
npm i some-legacy-cjs-lib # a package shipping only CommonJS
npm run dev # observe the resolution error in the browser console
How it works under the hood
Understanding where each shim lives makes the override choice mechanical rather than trial-and-error.
In dev, Vite does not bundle your application code — it serves each module over HTTP as native ESM and lets the browser drive resolution. But the browser cannot execute a require() call or a module.exports assignment, so any CommonJS dependency has to be converted first. Vite delegates that to esbuild’s dependency pre-bundling: on first request for a bare import, esbuild reads the package’s entry, runs cjs-module-lexer over it to guess the named exports, and emits a single ESM file into node_modules/.vite/deps/ that re-exports a synthetic default plus each detected name. That file carries an __esModule: true marker so that downstream import statements resolve against it. The conversion happens once and is cached, keyed by a hash of the lockfile, the Vite config, and the plugin set; the browser then only ever sees clean ESM. When the lexer’s guess is wrong or the dep was never scheduled for pre-bundling, you get the two dev-only errors — require is not defined and Cannot use import statement outside a module.
In the production build, none of that applies. vite build runs Rollup, and Rollup handles CommonJS through @rollup/plugin-commonjs, which parses the module’s AST and rewrites it into real ESM at bundle time rather than wrapping it. Rollup then validates every import against the exports it actually produced, and it is strict: if you write import pkg from 'cjs-lib' but the plugin could not prove the module has a default export, it fails the build with 'default' is not exported. The output.interop setting tells Rollup how liberal to be when synthesizing that default — this is the second stage, and it is completely independent of what esbuild did in dev.
The SSR build is a third path again. By default Vite externalizes bare dependencies for SSR, meaning it leaves the import in place and expects Node to resolve it at runtime. That is fast, but it hands the format problem to Node’s own loader, which will throw ERR_REQUIRE_ESM when an externalized CJS module transitively depends on an ESM-only package. ssr.noExternal opts a dependency out of externalization so Vite inlines it into the server bundle and resolves its format itself, the same way it does for the client.
Diagnosis workflow
-
Run with debug output.
npx vite dev --debugprints the internal resolution and transform logs; the namespace you want isvite:deps, soDEBUG='vite:deps' npx vite devnarrows the noise to pre-bundling decisions. Look for the line that names your package: it will either report a scan that scheduled it for optimization, or it will be absent entirely, which tells you esbuild never saw the import and the dep is being served raw. That single fact — scheduled versus skipped — separates a lexer failure from a resolution failure and decides whetheroptimizeDeps.includewill help at all. -
Inspect the pre-bundle. Look in
node_modules/.vite/deps/. Vite writes one converted.jsfile per optimized dependency plus a_metadata.jsonindex mapping each bare specifier to its output file and content hash. A package that is missing from that directory was never pre-bundled; a package whose file exists but still containsrequire(ormodule.exportswas pre-bundled but not converted, which points at a lexer or entry-resolution problem rather than a scheduling one. Open the file and read the first few lines — a healthy conversion starts withexport defaultand a list of named re-exports. -
Confirm the format.
grep -l "__esModule" node_modules/.vite/deps/*.jsshows which deps were wrapped for interop; absence of a wrapper on a CJS dep is the smoking gun. The__esModulemarker is the flag esbuild and Rollup both set when they have produced a synthetic default export, so a CJS dependency whose output file lacks it was treated as already-ESM and will hand the browser an object it cannot destructure. If the marker is present but a specific named import still fails, the format is fine and the problem has moved to the lexer’s export detection, covered in the gotchas below. -
Classify the error. Match the exact error string against the table below to pick the right override rather than guessing. The error text encodes which stage failed — a
ReferenceErrorin the browser console is dev, a Rollup'default' is not exportedwarning is build, and anERR_REQUIRE_ESMstack trace is Node during SSR. Reading the stage off the message is faster and more reliable than reasoning about the dependency, because the same dependency can produce all three depending on which command you ran.
The complete solution config
// vite.config.ts — Vite 5.x / 6.x, Node 20+
import { defineConfig } from 'vite';
export default defineConfig({
optimizeDeps: {
// Fix 1 — dev server "require is not defined" / "Cannot use import statement
// outside a module": force esbuild to pre-bundle the CJS dep into ESM.
include: ['legacy-cjs-lib'],
esbuildOptions: {
// Only needed when the dep ships non-standard syntax (e.g. JSX in .js)
loader: { '.js': 'jsx' },
},
},
build: {
rollupOptions: {
output: {
// Fix 2 — production "'default' is not exported by ...": let Rollup emit
// synthetic ESM wrappers around CJS module.exports. 'compat' is stricter.
interop: 'auto',
},
},
},
ssr: {
// Fix 3 — vite build --ssr: bundle the CJS dep instead of handing it to
// Node's require(), which avoids ERR_REQUIRE_ESM at runtime.
noExternal: ['node-cjs-dep'],
target: 'node', // 'node' keeps require/__dirname; 'webworker' strips Node globals
},
});
Each key is deliberately scoped to one dependency by name rather than applied globally, and that matters. optimizeDeps.include accepts an array of specifiers; listing only the offending package keeps Vite’s automatic scanner in charge of everything else, so you do not accidentally force-bundle deps that were resolving fine and pay their conversion cost on every cold start. The esbuildOptions.loader override is a separate concern — it only exists for the minority of packages that ship non-standard syntax in a .js extension, and applying it blindly to a normal dependency will slow the pre-bundle for no benefit.
output.interop deserves a note on its two useful values. 'auto' inspects each imported module and only synthesizes a default export when the module looks like real CommonJS, which is the correct default for a mixed dependency tree. 'compat' is stricter and closer to Babel’s esModuleInterop behavior: it always routes CJS access through a namespace object, which can fix an edge case where a dependency’s default and named exports collide, at the cost of a slightly larger wrapper. Start with 'auto' and only reach for 'compat' if a specific default import still resolves to undefined after the auto path.
For ssr.noExternal, the value can be an array of names or the boolean true to inline every dependency. Prefer the array: inlining everything defeats the point of externalization, inflates the server bundle, and can pull browser-incompatible code into a build that was fine. If several related packages from one scope all need inlining, a single regular expression such as /@scope\// is cleaner than listing each one and survives minor version bumps.
Verification
After applying the override, prove the fix instead of trusting the absence of an error:
# Dev: a clean cold start must rebuild the pre-bundle with the dep converted
rm -rf node_modules/.vite && npx vite dev --force
# Then confirm the dep was wrapped for interop:
grep -l "legacy-cjs-lib" node_modules/.vite/deps/_metadata.json
# Build: production output must contain no unresolved 'default' export error
npx vite build && npx vite preview
A successful vite build exits 0 with no 'default' is not exported warning, and the SSR bundle (dist/server) contains the dependency inline rather than a bare require('node-cjs-dep').
The --force flag is not optional in this workflow. Vite’s pre-bundle cache is keyed on inputs, not on the correctness of its output, so editing optimizeDeps.include without invalidating the cache can leave the old, broken conversion in place and make it look like your change did nothing. Deleting node_modules/.vite and passing --force guarantees a cold conversion against the current config. Confirm success by opening the regenerated file under node_modules/.vite/deps/ and checking that it now begins with export default and lists the names you import — the absence of a console error is weaker evidence, because a lazily-loaded dependency may simply not have been requested yet.
For the SSR path, the confirmation is structural rather than behavioral. Grep the emitted server bundle for the dependency’s own source strings; if you find them, the dep was inlined and noExternal took effect. If instead you find a literal require('node-cjs-dep') or an import of the bare name, the dep is still externalized and Node will resolve it at runtime — which is exactly the configuration that throws ERR_REQUIRE_ESM.
| Exact error string | Root cause | Override |
|---|---|---|
SyntaxError: Cannot use import statement outside a module |
CJS file loaded as ESM in the browser | optimizeDeps.include: ['pkg'] |
ReferenceError: require is not defined |
Pre-bundle skipped for a CJS dep | optimizeDeps.include: ['pkg'] |
'default' is not exported by node_modules/<pkg>/index.js |
Rollup strict ESM validation | build.rollupOptions.output.interop: 'auto' |
Failed to resolve entry for package '<pkg>' |
Missing or malformed exports field |
resolve.alias or optimizeDeps.include |
ERR_REQUIRE_ESM during --ssr |
CJS externalized but depends on ESM | ssr.noExternal: ['pkg'] |
Worked example: a chart library that only ships CommonJS
A concrete case ties the three fixes together. Suppose you depend on legacy-charts, a package whose entry is module.exports = { createChart, registerTheme } with no exports map and no ESM build. In application code you naturally reach for a named import, and it fails in dev with require is not defined, then — after you add it to optimizeDeps.include — fails the SSR render with ERR_REQUIRE_ESM because the chart library pulls in an ESM-only color utility. The import site and the config that makes it work look like this:
// chart-panel.ts — Vite 5.x / 6.x, Node 20+
// The lexer detects these names because legacy-charts assigns them
// as top-level exports.foo = ...; a default-import fallback is shown
// in the gotchas section for packages where it cannot.
import { createChart, registerTheme } from 'legacy-charts';
registerTheme('dark');
export const chart = createChart(document.querySelector('#root')!);
// vite.config.ts — the two keys this specific dep needs
import { defineConfig } from 'vite';
export default defineConfig({
optimizeDeps: {
// dev: convert the CJS entry so the browser sees named ESM exports
include: ['legacy-charts'],
},
ssr: {
// SSR: inline it so Vite (not Node) resolves its ESM-only dependency
noExternal: ['legacy-charts'],
},
});
Note what is absent: this dependency needs no output.interop override, because its exports are statically assigned and Rollup detects the default on its own. Adding interop: 'compat' here would be cargo-culting — it would change the wrapper shape for every CJS module in the build to fix a problem this one does not have. Scope each override to the dependency and the stage that actually fails.
Gotchas & edge cases
-
Stale pre-bundle masks the fix. Vite caches
optimizeDepsoutput and only invalidates it when its input hash changes, so a config edit that Vite does not consider hash-affecting can leave a broken wrapper in place. The symptom is a fix that “does nothing” — the error persists identically after you were sure you changed the config. The fix is to deletenode_modules/.viteand start with--force; confirm by checking the regenerated file’s timestamp is newer than your edit. -
Named exports still fail after
interop: 'auto'.cjs-module-lexerperforms a shallow static scan and cannot detect exports that are assigned dynamically — inside a loop, behind a conditional, or viaObject.assign(module.exports, ...). The symptom is a named import that resolves toundefinedeven though the value clearly exists at runtime. The fix is to default-import the whole namespace and destructure from it, which sidesteps static detection entirely:import pkg from 'lib'; const { thing } = pkg;. See resolving “named export not found” errors for the full pattern and how to confirm which exports the lexer actually found. -
Dual-package hazard from
noExternal. When you inline a dependency for SSR but a transitive dependency still imports the externalized copy, you end up with two live instances of the same module, each with its own module-level state. The symptom is subtle: a singleton, cache, or registry appears empty on the server even though it was populated, because the reader and writer hold different copies. Pin a single instance withresolve.dedupe: ['the-dep']and confirm by grepping the server bundle for more than one copy of the dependency’s source. -
vite-plugin-commonjsis a last resort. The community plugin runs an additional Babel-based transform over CommonJS at request time, which does handle some dynamic-requirepatterns the native path cannot — but it also slows every cold start and can mask the real entry-resolution bug. Reach for nativeoptimizeDepsand Rollupinteropfirst, and only add the plugin for a dependency that provably cannot be lexed. If cold-start time regresses after adding it, that is the plugin, not your app.
Performance considerations
Every dependency in optimizeDeps.include is converted by esbuild on the first cold start after a cache invalidation, and that work is on the critical path of the first vite dev boot. For a handful of packages this is negligible, but force-including a large tree — or setting ssr.noExternal: true to inline everything — turns a fast incremental start into a full bundle and can add seconds to boot. The pre-bundle cache amortizes this across subsequent starts, so the cost is paid once per lockfile or config change rather than per boot; the practical guidance is to include only the specifiers that actually fail, keep the list short, and let Vite’s automatic scanner handle the rest. In the production build, output.interop has near-zero runtime cost because the wrapper is generated once at bundle time and tree-shaken away where a default export is unused.
CI integration
Interop bugs are exactly the class of failure that hides in local caches, so CI must exercise all three stages from a clean state. A minimal gate runs rm -rf node_modules/.vite && vite build followed by an SSR smoke render, treating any Rollup 'default' is not exported warning as fatal. Because those warnings do not by default fail the build, add an onwarn handler in rollupOptions that rejects on the interop codes, or grep the build log and exit non-zero on a match. Running the build in CI on the same Node major version you deploy on matters too: ERR_REQUIRE_ESM behavior is tied to Node’s loader, and a dependency that externalizes cleanly on Node 20 can fail on a newer major that tightens require-of-ESM rules.
When not to use this
These overrides fix consuming a CommonJS dependency; they are the wrong tool if the dependency ships a correct dual-format package.json and the failure is really a version or resolution-condition mismatch. In that case the honest fix is upstream — file an issue, or map the ESM condition explicitly rather than papering over it with interop. Likewise, if you control the dependency, converting it to publish real ESM removes the need for every override here and is strictly better than pinning interop settings across every consumer. Reach for this configuration when the dependency is legacy, unmaintained, or intentionally CommonJS-only and rewriting it is not an option.
Related
- Understanding ESM vs CommonJS in Modern Bundlers — the condition-matching and dual-package model behind these fixes.
- Resolving “named export not found” errors — the lexer-detection variant of this problem.
- Core Concepts of Modern Bundling — how Vite’s dual-bundler pipeline resolves modules end to end.