ESM and CJS Interoperability
A modern JavaScript package is expected to load cleanly whether a consumer writes import, require, a tsconfig with moduleResolution: "bundler", or a webpack config from 2019. Getting that right means shipping deterministic dual-module artifacts, declaring an explicit exports map, and understanding exactly how Node.js picks one file over another. Get it wrong and you ship ERR_REQUIRE_ESM, SyntaxError: Named export not found, or a package that silently loads twice and corrupts its own singletons.
This guide covers the resolution boundary between ECMAScript modules and CommonJS, the build pipeline that emits both formats, and the runtime bridges that let them coexist in one process. The manifest fields that drive all of it are documented in Understanding package.json Fields, and the type-definition half of dual packaging lives in TypeScript Declaration Publishing.
Node.js module resolution and package configuration
Define explicit module boundaries with the exports field. Map conditional exports for import, require, and default so neither Node.js nor a bundler has to guess. The type field and conditional routing follow the parsing rules in Understanding package.json Fields.
Conditional exports syntax
Route bundlers and Node.js to the correct artifact explicitly. Never rely on implicit file resolution.
{
"name": "@scope/dual-pkg",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": { "types": "./dist/index.d.mts", "default": "./dist/index.mjs" },
"require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
},
"./internal/*": null
}
}
Setting "./internal/*": null blocks consumers from reaching into private directories. Condition order matters: Node.js evaluates conditions top to bottom and takes the first match, so default must always come last.
Strict type enforcement
Node.js defaults to CommonJS when type is omitted. Enforce ESM at the package root or use explicit extensions:
# Verify the package's declared type
node -e "console.log(require('./package.json').type)"
# With "type": "module", every .js file parses as ESM.
# Use .cjs for any CommonJS entry point that remains.
Why strict exports matter
Legacy main/module fields allow unpredictable fallback chains. When no exports map is present, Node.js and bundlers apply heuristics that can load the wrong format — the root cause of most interop bugs. Terminating every conditional branch with default and nullifying internal paths removes the ambiguity. When the require branch is missing entirely, a CommonJS consumer hits the failure detailed in Fixing ERR_REQUIRE_ESM in Node.js; when the ESM branch points at a CJS file with no real named bindings, consumers hit the error covered in Resolving 'Named Export Not Found' in ESM.
Dual-output build pipeline architecture
Configure your bundler to emit parallel ESM and CJS artifacts, pin the toolchain version, and validate the outputs against a strict schema. Wiring the build into Core JavaScript Package Workflows keeps CI/CD reproducible. Apply tree-shaking only to the ESM output so the CJS build stays self-contained.
tsup configuration
tsup provides near-zero-config dual-output generation.
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
splitting: false,
sourcemap: true,
clean: true,
outDir: 'dist',
esbuildOptions(options) {
options.target = 'node18';
}
});
TypeScript compiler flags
Align the compiler with Node.js native resolution so emitted code matches what Node actually loads.
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"isolatedModules": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Artifact validation in CI
# .github/workflows/build-validate.yml
name: Validate dual outputs
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npx tsup
- name: Verify ESM and CJS artifacts exist
run: |
test -f dist/index.mjs || { echo "ESM missing"; exit 1; }
test -f dist/index.cjs || { echo "CJS missing"; exit 1; }
sha256sum dist/index.mjs dist/index.cjs > dist/SHASUMS.txt
- name: Smoke-test both entry points
run: |
node -e "import('./dist/index.mjs').then(() => console.log('esm ok'))"
node -e "require('./dist/index.cjs'); console.log('cjs ok')"
The smoke test catches the two most common publish-time regressions — a missing require condition and a CJS artifact that an ESM consumer cannot name-import — before they reach the registry.
Runtime interoperability and dynamic loading
Bridge formats with createRequire for ESM-to-CJS and dynamic import() for CJS-to-ESM. Validate any dynamic path against an allowlist to prevent arbitrary code execution. Keeping dependency versions identical across environments depends on Lockfile Management Strategies so a bridge does not load two different copies.
createRequire implementation
Load a legacy CJS module from inside an ESM context.
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
// ESM -> CJS bridge
const require = createRequire(import.meta.url);
const legacyPkg = require('legacy-cjs-pkg');
// CJS -> ESM (dynamic) with path validation
async function loadEsmModule(pkgName) {
const resolved = resolve(__dirname, 'node_modules', pkgName);
if (!resolved.startsWith(resolve(__dirname))) throw new Error('Path traversal blocked');
return import(resolved);
}
Dynamic import security controls
Never pass unsanitized input to import(). Enforce a strict allowlist.
const ALLOWED_MODULES = new Set(['lodash', 'chalk', 'uuid']);
export async function safeDynamicImport(moduleName) {
if (!ALLOWED_MODULES.has(moduleName)) {
throw new Error(`Module "${moduleName}" not in allowlist`);
}
return import(moduleName);
}
The dual-package hazard
The most insidious interop bug is the dual-package hazard: a consumer loads your ESM build through one dependency and your CJS build through another, ending up with two copies of your library in a single process. Each copy has its own module-level state, so instanceof checks fail across the boundary, registered singletons diverge, and caches do not share entries.
// Symptom: an object created by the ESM copy fails an instanceof
// check run by the CJS copy, even though it is "the same" class.
import { Token } from '@scope/dual-pkg'; // ESM copy
const { Token: TokenCjs } = require('@scope/dual-pkg'); // CJS copy
new Token() instanceof TokenCjs; // false — two distinct classes
Mitigate it by keeping all stateful logic in a single shared internal module and having both the ESM and CJS entry points re-export from it, or by isolating state in an external store rather than module scope. For libraries whose identity matters (validators, dependency-injection containers, plugin registries), prefer shipping ESM-only and documenting the CJS bridge via createRequire, rather than maintaining two parallel stateful builds.
Bundler resolution conditions
Node.js is not the only resolver that reads your exports map. Bundlers add their own conditions on top of import/require/default, and getting them wrong sends webpack or Vite to the wrong artifact.
| Condition | Resolved by | Purpose |
|---|---|---|
import |
Node.js, bundlers | ESM entry for import/import(). |
require |
Node.js, bundlers | CJS entry for require(). |
module |
bundlers only | ESM build a bundler prefers for tree-shaking. |
browser |
bundlers only | Browser-safe replacement for a Node entry. |
default |
all | Mandatory final fallback. |
Order conditions from most specific to least specific, and never place default before a more specific key — the first match wins, so a misplaced default shadows everything after it.
Cross-environment state management
ESM and CJS maintain separate module caches. When a package ships both builds and one process reaches it from both sides, you get two distinct instances — two copies of every singleton. Share state through an explicit external store or a globalThis Symbol registry rather than require.cache or module-level globals.
Testing matrix and production validation
Run isolated test suites against both the ESM and CJS entry points. Validate Node.js compatibility (18+ for stable native ESM).
Vitest configuration
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
alias: {
'@/': './src/'
}
}
});
Run the matrix:
# ESM suite
node --experimental-vm-modules node_modules/.bin/vitest run --config vitest.config.ts
# CJS suite
NODE_OPTIONS="--require tsx/cjs" npx vitest run --config vitest.cjs.config.ts
Node.js version compatibility matrix
| Node version | ESM support | CJS interop | Recommended action |
|---|---|---|---|
18.x |
Native | Stable | Baseline target; enable --experimental-vm-modules for Vitest ESM mode. |
20.x |
Native | Stable | Default CI target; full import() and createRequire support. |
22.x |
Native | Stable | Enable --experimental-strip-types for direct TypeScript execution. |
Bundle size and tree-shaking audits
# Analyze the ESM bundle size with esbuild
npx esbuild dist/index.mjs --bundle --minify --outfile=/dev/null --metafile=meta.json
# Verify the sideEffects declaration
grep -q '"sideEffects"' package.json || echo "Add \"sideEffects\": false to enable tree-shaking"
Common pitfalls and remediation
| Mistake | Impact | Resolution |
|---|---|---|
Legacy main/module without exports |
Ambiguous resolution order, bundler conflicts. | Use a strict exports map with explicit conditional keys as the primary path. |
Omitting the default condition |
Node.js and bundlers fail when no specific condition matches. | Always end each conditional chain with default. |
Mixing .js files without type |
Node.js defaults to CJS and breaks native ESM syntax. | Use .mjs/.cjs extensions or set "type": "module". |
Missing require condition |
CJS consumers hit ERR_REQUIRE_ESM. |
Add a require branch pointing at a compiled .cjs build. |
| ESM branch points at a CJS file | SyntaxError: Named export not found. |
Emit a real .mjs with named bindings, or re-export through a wrapper. |
eval() / new Function() for interop |
Arbitrary code execution risk. | Use native import() with path validation and createRequire. |
Frequently Asked Questions
Why does Node.js throw ERR_REQUIRE_ESM when importing my package?
The package has no valid require condition in its exports map, or the resolved file is ESM-only (via .mjs or "type": "module") with no CommonJS fallback. Add a require conditional export pointing at a compiled .cjs artifact so require() resolves a CJS file.
How do I handle TypeScript esModuleInterop when publishing dual packages?
Enable esModuleInterop so the compiler emits synthetic default imports for CJS consumers, and ensure your bundler (tsup or Rollup) strips those shims from the native ESM output to avoid duplicated wrapper functions.
Is it safe to use wildcard exports such as "./utils/*": "./dist/utils/*.js" in production?
Yes when the pattern is specific. The trade-off is discoverability, not safety — explicit entry points are easier for consumers and static analyzers to reason about, so reserve wildcards for large APIs where enumerating every path is impractical.
Should I ship source maps for both ESM and CJS outputs?
Ship them for development, but keep them external (tsup sourcemap: true emits separate .map files) so consumers opt in without paying the bundle-size cost. Never inline source maps into published .mjs/.cjs files by default.
Related
- Fixing ERR_REQUIRE_ESM in Node.js — repair the missing-require-condition failure that blocks CommonJS consumers.
- Resolving 'Named Export Not Found' in ESM — fix the named-import error caused by routing ESM consumers to a CJS file.
- Understanding package.json Fields — the
exports,type, andmainfields that drive conditional resolution. - TypeScript Declaration Publishing — ship matching
.d.mts/.d.ctstypes alongside dual JavaScript builds. - Lockfile Management Strategies — keep one resolved copy per dependency so runtime bridges never load duplicates.