How to configure ESM and CJS interop in Vite
Vite’s native ESM-first architecture frequently triggers module resolution failures when consuming legacy CommonJS packages. This guide provides exact configuration patterns to resolve interop conflicts without compromising dev server performance or production tree-shaking. For foundational context on module format differences, review Understanding ESM vs CommonJS in Modern Bundlers before applying the fixes below.
Root-Cause Analysis: Why Vite Throws ESM/CJS Interop Errors
The core issue stems from Vite’s dual-bundler architecture: the dev server uses native browser ESM with esbuild pre-bundling, while production relies on Rollup. When a CJS dependency lacks proper exports mapping or uses dynamic require(), the pipeline fails to transform it. Understanding how modern bundlers handle module resolution is critical; see Core Concepts of Modern Bundling for architectural context. Common failure points include missing pre-bundle entries, incorrect interop output settings, and SSR runtime mismatches.
Vite’s dev server relies on native browser ESM, bypassing traditional bundling. CJS packages lack native browser support, requiring esbuild pre-bundling to transform require() to ESM. Production builds use Rollup, which handles interop via interop output options. Misconfigured package.json exports, missing optimizeDeps entries, or unhandled SSR externalization break this pipeline, causing runtime resolution failures.
Step-by-Step Configuration Fixes
Fix 1: Optimizing Dependencies with optimizeDeps
Resolve dev server ReferenceError: require is not defined and pre-bundle failures by forcing esbuild to pre-bundle problematic CJS packages. Add the dependency to optimizeDeps.include. If the package contains non-standard syntax, configure esbuildOptions with custom loaders. This prevents runtime parsing errors during HMR.
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
optimizeDeps: {
include: ['legacy-cjs-lib'],
esbuildOptions: {
loader: { '.js': 'jsx' }
}
}
});
Fix 2: Production Build Rollup Overrides (rollupOptions)
Fix Error: 'default' is not exported by node_modules/<package>/index.js during vite build by configuring build.rollupOptions.output.interop. Set it to 'auto' or 'compat' to instruct Rollup to generate synthetic ESM wrappers around CJS module.exports objects. Provide explicit namedExports when destructured imports fail to map correctly.
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
interop: 'auto'
}
}
}
});
Fix 3: SSR/Node Interop with ssr.noExternal
When running vite build --ssr, CJS packages must be bundled rather than externalized. Add them to ssr.noExternal to force Rollup to process them. The ssr.target setting dictates the runtime environment: 'node' preserves require() and __dirname, while 'webworker' strips Node globals and expects ESM.
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
ssr: {
noExternal: ['node-cjs-dep'],
target: 'node'
}
});
Debugging Interop Failures in Vite
Enable verbose logging with vite --debug to trace dependency resolution paths and identify where the pipeline drops CJS modules. Inspect the .vite/deps directory to verify pre-bundle output; missing or malformed files indicate optimizeDeps misconfiguration. Use vite-plugin-commonjs only as a last resort when native interop fails.
| Exact Error String | Root Cause | Configuration Override |
|---|---|---|
SyntaxError: Cannot use import statement outside a module |
CJS file loaded as ESM in browser | optimizeDeps.include: ['pkg'] |
ReferenceError: require is not defined |
Pre-bundle skipped for CJS dep | optimizeDeps.include: ['pkg'] |
Error: 'default' is not exported by node_modules/<package>/index.js |
Rollup strict ESM validation | build.rollupOptions.output.interop: 'auto' |
Failed to resolve entry for package '<package>'. The package may have incorrect main/module/exports specified in its package.json. |
Missing or malformed exports field |
resolve.alias or optimizeDeps.include |
Best Practices for Framework Maintainers
Recommend strict adherence to package.json conditional exports ("import" vs "require"). Structure dual ESM/CJS packages to avoid the dual-package hazard by ensuring both entry points resolve to identical internal state. Interop shims introduce minor runtime overhead and increase bundle size; prioritize migration strategies toward pure ESM. When publishing libraries, validate outputs against both Vite and Node.js module resolution algorithms to guarantee ecosystem compatibility.