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.
Node decides a module's format before it parses it, from three signals in priority order: an explicit --input-type flag, the file extension (.mjs is always ESM, .cjs always CommonJS), and otherwise the nearest package.json type field. This is why the same import statement runs in one file and throws SyntaxError: Cannot use import statement outside a module in another — the format is a property of how the file is resolved, not of its contents. Making the signal explicit with a type field or an unambiguous extension removes a whole class of format-detection surprises.
The exports map is where a package declares which format a consumer gets, and Node evaluates its conditions strictly and in order. The import condition serves ESM consumers, the require condition serves CommonJS consumers, and types (which must come first) serves type checkers. A package that ships only an import condition is ESM-only and cannot be required synchronously; one that ships both is dual-format and must take care that the two builds do not duplicate stateful singletons.
A correct dual-format manifest makes the conditional resolution explicit, nesting a types condition inside both the import and require branches so type checkers and the runtime resolve the format-appropriate file for each import style:
{
"type": "module",
"main": "./dist/index.cjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.mjs" },
"require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
}
}
}
The top-level main and types remain as fallbacks for legacy resolvers that do not read the exports map, while modern node16/nodenext resolution uses the nested conditions. This belt-and-suspenders shape is what makes a package resolve correctly across every resolution mode a consumer might use, rather than only the one you happened to test.
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.
A dual-output build emits both formats from one source, and the details of how the outputs differ decide whether consumers hit interop errors. The ESM output uses import/export and a .mjs extension or type: module context; the CommonJS output uses module.exports and a .cjs extension. Each must be paired with a matching declaration — .d.ts for ESM, .d.cts for CommonJS — wired through nested types conditions in the exports map. Tools like tsup produce both with minimal configuration, but the correctness of the exports wiring is yours to verify, because a build that is right with a map that is wrong still fails for consumers.
A minimal tsup configuration produces the dual output plus declarations, and the manifest wires each format to its files:
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
clean: true,
external: ['react', 'react-dom'],
});
The dts: true flag emits declarations alongside the JavaScript, and external keeps peers out of the artifact. The correctness of the build then depends on the exports map pairing each format with its own file and declaration — a build that is right with a map that is wrong still fails for consumers, which is why the map and the build must be validated together against the packed tarball.
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.
The interop rules are asymmetric, which is the source of most confusion. ESM can load CommonJS natively — a CommonJS module's module.exports becomes the ESM default import — but named imports from a CommonJS module work only when Node's static analysis can detect the named exports, so destructuring sometimes requires importing the default and destructuring from it. CommonJS, by contrast, cannot synchronously require an ESM module, because ESM evaluates asynchronously; the bridge is a dynamic import(), which works inside any CommonJS module and returns a promise for the namespace.
Two helpers cover the remaining cases. createRequire from node:module builds a require function inside an ESM file, for loading a CommonJS dependency by path from ESM — it does not help load ESM from CommonJS. And Node 22 added the ability to require() an ESM module with no top-level await, unflagged on newer lines, which softens the boundary for application code but is not portable to Node 18 or 20 and still fails on ESM using top-level await. A published library must therefore still ship a real require condition rather than assume the flag.
The bridges between the two module systems have concrete shapes worth keeping to hand. From CommonJS reaching an ESM-only package, a dynamic import() is the portable answer; from ESM loading a CommonJS dependency by path, createRequire builds a require:
// CommonJS reaching an ESM-only package
async function load() {
const { render } = await import('esm-only-renderer');
return render;
}
// ESM loading a CommonJS dependency by path
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const legacy = require('old-cjs-only-pkg');
The asymmetry is the thing to internalize: ESM can load CommonJS natively (the default import is module.exports), and named imports work when Node can statically detect the exports; CommonJS cannot synchronously require ESM and must use the async import() bridge, because ESM evaluates asynchronously and require is synchronous.
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"
Interop correctness is verifiable, so a dual-format package should prove it in CI rather than hope for it. The essential test is a resolution matrix: from a .mjs file, import the built package; from a .cjs file, require it; and type-check both under node16 resolution so the declarations resolve to the format-appropriate file. This exercises the real conditional resolution end to end — the JavaScript loads under each format and the types resolve correctly — catching the reordered condition, the missing .d.cts, and the named-export mismatch that a source-importing test never sees.
Complement the runtime matrix with the static checkers that resolve your package the way a consumer's tooling will. publint inspects the exports map for structural mistakes, and @arethetypeswrong/cli packs the tarball and reports a grid of every module-and-resolution combination, flagging a false CJS or a missing-types cell precisely. Running the smoke tests plus both linters in CI turns interop from a source of consumer-reported bugs into a gate on your own pipeline, so a boundary mistake fails the pull request that introduced it rather than a consumer's install weeks later.
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. |
The interop pitfalls share a root cause: assuming your intent, rather than the target file's format, decides what loads. ERR_REQUIRE_ESM fires when CommonJS requires an ESM target; the fix is to convert the caller, bridge with dynamic import(), or ship a require condition. A missing named export fires when ESM destructures from a CommonJS default; the fix is to import the default and destructure from it. The dual-package hazard fires when both formats load and duplicate a singleton; the fix is a single shared stateful module. Each is prevented by the same discipline — validate the published resolution the way a consumer will.
A useful way to hold the interop rules is a small table of what fails and why. require() of an ESM target throws ERR_REQUIRE_ESM because require is synchronous and ESM evaluates asynchronously; a named import from a CommonJS module can throw when Node's static analysis cannot detect the export, so importing the default and destructuring from it is the reliable form; and both formats loaded at once can duplicate a singleton. Each has a deterministic fix, and none is resolved by guessing — the target file's format, not your intent, decides what the loader will do.
The prevention that covers all of these is to validate the published resolution the way a consumer will, in CI. A smoke test that both requires and imports the built package, type-checked under node16, plus publint and @arethetypeswrong/cli against the packed tarball, exercises every combination a consumer might hit. A boundary mistake then fails your pipeline on the pull request that introduced it, rather than surfacing weeks later as a consumer's install error that you cannot reproduce from your source-importing tests.
The dual-package hazard and single-instance state
The subtlest failure in dual publishing is the dual-package hazard: when a consumer's graph reaches your package through both import and require, Node resolves each condition to a different physical build and instantiates each independently. For a stateless utility this is merely wasteful, but any singleton — a registry, a cache, a class checked with instanceof — now exists twice, and the two copies desynchronize in ways that resist debugging: a provider that does not match its consumer, an instanceof that returns false for an object that visibly is that class.
The defense is to ensure any identity-bearing state has exactly one physical module regardless of entry format. Keep the singleton in a small module both builds reference — commonly a single CommonJS file both require — so Node instantiates it once, and let the rest of the package remain dual-format. Where you do not need synchronous require support at all, shipping ESM-only removes the hazard entirely by construction, since a single-format package cannot load twice. The choice is between a single-format singleton that preserves dual publishing and an ESM-only package that trades some consumer reach for structural simplicity.
A testing matrix that proves interop before publish
Interop correctness is verifiable, so a dual-format package should prove it in CI rather than hope for it. The essential test is a resolution matrix: from a .mjs file, import the built package; from a .cjs file, require it; and type-check both under node16 resolution so the declarations resolve to the format-appropriate file. This exercises the real conditional resolution end to end — the JavaScript loads under each format and the types resolve correctly — catching the reordered condition, the missing .d.cts, and the named-export mismatch that a source-importing test never sees.
Complement the matrix with the static checkers. publint inspects the exports map for structural mistakes, and @arethetypeswrong/cli packs the tarball and reports a grid of every module-and-resolution combination, flagging a false CJS or a missing-types cell precisely. Running both plus the smoke tests in CI turns interop from a source of consumer-reported bugs into a gate on your own pipeline, so a boundary mistake fails the pull request that introduced it rather than a consumer's install weeks later.
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.
Why does the same import work in one file but throw in another?
Node decides a file's format before parsing it, from the extension or the nearest package.json type. If the signals say CommonJS while the file uses import, the parser rejects it. Make the signal explicit with a type field or a .mjs/.cjs extension.
How do I keep a singleton single across ESM and CJS builds?
Put the stateful code in one module both builds reference — often a single .cjs file both require — so Node instantiates it once. Or ship ESM-only if you do not need synchronous require, which removes the dual-package hazard by construction.
How do I prove my dual-format package interops correctly?
Run a resolution matrix in CI: import the built package from a .mts file, require it from a .cts file, and type-check both under node16. Add publint and @arethetypeswrong/cli, and interop mistakes fail your build instead of a consumer's install.
What's the safest module strategy for a new package?
ESM-first: type: module, an exports map, and a dual build only if you must support CommonJS consumers that cannot use a dynamic import(). ESM-only is simpler and free of the dual-package hazard; add the require condition only when your audience genuinely needs it.
How do I test that my dual-format package interops correctly?
Run a resolution matrix in CI: import the built package from a .mts file, require it from a .cts file, and type-check both under node16. Add publint and @arethetypeswrong/cli, and interop mistakes fail your build instead of a consumer's install.
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.