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.

Vite's dual-bundler path for a CommonJS dependency A CJS dependency flowing through esbuild pre-bundling in dev and the Rollup commonjs plugin in production, with the SSR externalization branch. CJS dependency module.exports = ... require("...") Dev: esbuild optimizeDeps pre-bundle Build: Rollup plugin-commonjs + interop SSR: Node ssr.noExternal bundles it Browser/server ESM import default + named
Figure: the same CJS dependency takes three paths — esbuild pre-bundling in dev, the Rollup commonjs plugin in build, and ssr.noExternal in SSR.

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.

The same CJS dep, two different bundlers In dev the CJS dependency is converted by esbuild pre-bundling; in the build it is handled by the Rollup commonjs plugin — so an interop bug can appear in one path but not the other. CJS dependencymodule.exports / require dev: esbuild pre-bundleoptimizeDeps build: Rollup plugin-commonjsinterop A bug can pass dev and fail build (or vice versa)
Figure: two engines mean interop must be verified in both dev and build — a fix in one path is not automatic in the other.
# 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

Four-step Vite interop diagnosis Run vite dev --debug to find where the CJS module is dropped, inspect node_modules/.vite/deps for a missing pre-bundle, grep for __esModule to confirm the wrapper, then classify the exact error string against the override table. 1 --debugwhere dropped? 2 .vite/depsconverted? 3 grep __esModulewrapper? 4 classify errorpick override
Figure: step 4 — match the exact error string to its override, don't guess.
  1. Run with debug output. npx vite dev --debug prints the internal resolution and transform logs; the namespace you want is vite:deps, so DEBUG='vite:deps' npx vite dev narrows 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 whether optimizeDeps.include will help at all.

  2. Inspect the pre-bundle. Look in node_modules/.vite/deps/. Vite writes one converted .js file per optimized dependency plus a _metadata.json index 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 contains require( or module.exports was 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 with export default and a list of named re-exports.

  3. Confirm the format. grep -l "__esModule" node_modules/.vite/deps/*.js shows which deps were wrapped for interop; absence of a wrapper on a CJS dep is the smoking gun. The __esModule marker 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.

  4. 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 ReferenceError in the browser console is dev, a Rollup 'default' is not exported warning is build, and an ERR_REQUIRE_ESM stack 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

Three fixes, one per pipeline path optimizeDeps.include fixes the dev-server require-is-not-defined error, output.interop auto fixes the production default-not-exported error, and ssr.noExternal fixes ERR_REQUIRE_ESM during SSR. dev pathoptimizeDeps.includerequire is not defined build pathoutput.interop: 'auto'default not exported SSR pathssr.noExternalERR_REQUIRE_ESM One override per path — each fixes a different error
Figure: the three overrides map one-to-one onto the three pipeline paths in the lead diagram.
// 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:

Prove the fix in dev and build Clear the pre-bundle cache and cold-start with --force to confirm the dep was converted, then run vite build and preview to confirm no default-not-exported warning and that the SSR bundle inlines the dependency. rm .vite + dev --forceconverted? vite build + previewno export warning SSR bundle inlines depno bare require()
Figure: verify all three paths — a fix that only clears the dev error can still fail the build or SSR.
# 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

Four Vite interop edge cases A stale pre-bundle masks the fix so use --force; named exports still fail after interop auto because the lexer can't detect dynamic assignment; noExternal can fork state into a dual-package hazard; and vite-plugin-commonjs is a slow last resort. stale pre-bundle Vite caches optimizeDeps — always re-run with --force after editing include. named exports still fail the lexer misses dynamic assignment — default-import then destructure. noExternal dual-package bundling for SSR while a transitive copy stays external forks state — resolve.dedupe. plugin-commonjs last resort reach for native optimizeDeps + interop first; the plugin slows cold start.
Figure: one caching trap, one lexer limit, one state-forking risk, and one performance last-resort.
  • Stale pre-bundle masks the fix. Vite caches optimizeDeps output 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 delete node_modules/.vite and 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-lexer performs a shallow static scan and cannot detect exports that are assigned dynamically — inside a loop, behind a conditional, or via Object.assign(module.exports, ...). The symptom is a named import that resolves to undefined even 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 with resolve.dedupe: ['the-dep'] and confirm by grepping the server bundle for more than one copy of the dependency’s source.

  • vite-plugin-commonjs is a last resort. The community plugin runs an additional Babel-based transform over CommonJS at request time, which does handle some dynamic-require patterns the native path cannot — but it also slows every cold start and can mask the real entry-resolution bug. Reach for native optimizeDeps and Rollup interop first, 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.