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.
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:
# 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
Diagnosis workflow
- Run with debug output.
npx vite dev --debugprints dependency-resolution paths; find the line where the CJS module is dropped or mis-converted. - Inspect the pre-bundle. Look in
node_modules/.vite/deps/. A missing or malformed file for the package meansoptimizeDepsskipped or failed to convert it. - 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. - Classify the error. Match the exact error string against the table below to pick the right override rather than guessing.
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
},
});
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').
| 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'] |
Gotchas & edge cases
- Stale pre-bundle masks the fix. Vite caches
optimizeDepsoutput; always re-run with--forceafter editinginclude, or the old wrapper persists. - Named exports still fail after
interop: 'auto'.cjs-module-lexercannot detect dynamically assigned exports. Default-import then destructure, and see resolving “named export not found” errors for the full pattern. - Dual-package hazard from
noExternal. Bundling a dep for SSR while a transitive copy stays externalized can fork its state; pin one copy withresolve.dedupe. vite-plugin-commonjsis a last resort. Reach for nativeoptimizeDepsand Rollupinteropfirst; the plugin adds an extra transform pass that slows cold start.
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.