- Published on
ESM vs CommonJS in 2026 — The Definitive Guide to Node.js Module Interop
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
In 2026, ESM is the standard, but CommonJS hasn't died. Node.js 22 added the missing link: require(esm). This post covers the complete interop landscape and teaches you to navigate both ecosystems with confidence.
Current State of ESM in Node.js 22
Node.js 22 LTS shipped with major ESM improvements:
- Native ESM is stable and default for
.mjsand"type": "module" require(esm)now works (the missing feature that justified CommonJS)import.meta.resolve()for dynamic imports- Top-level
awaitin async contexts - Better error messages for ESM/CJS mismatches
// package.json
{
"type": "module",
"exports": {
".": "./dist/index.js",
"./cli": "./dist/cli.js"
}
}
With "type": "module", all .js files are treated as ESM. Use .cjs for CommonJS:
// index.js (ESM)
export function greet(name) {
return `Hello, ${name}!`;
}
// legacy.cjs (CommonJS)
module.exports = { greet: (name) => `Hello, ${name}!` };
require(esm) in Node 22 — The Missing Piece
The biggest change: CommonJS code can now require() ESM modules:
// api.mjs (ESM)
export async function fetchUser(id) {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
// index.cjs (CommonJS)
const { fetchUser } = require('./api.mjs'); // ✓ Works in Node 22!
fetchUser(1).then(user => {
console.log(user);
});
This enables gradual ESM migration in legacy CommonJS projects. You don't have to convert everything at once.
Limitations:
- Top-level
awaitin ESM cannot be required (must use async/await) - Default exports require destructuring:
const { default: api } = require('./api.mjs')
// api.mjs with default export
export default {
fetchUser: async (id) => { /* ... */ },
};
// index.cjs
const api = require('./api.mjs'); // ✓ Default export comes through
api.fetchUser(1);
package.json exports Field for Dual Publishing
Define entry points for both ESM and CommonJS consumers:
{
"name": "@repo/utils",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
},
"./math": {
"import": "./dist/math/index.js",
"require": "./dist/math/index.cjs"
}
},
"files": ["dist"]
}
Consumers get the right format automatically:
// ESM consumer
import { sum } from '@repo/utils';
import { add } from '@repo/utils/math';
// CommonJS consumer
const { sum } = require('@repo/utils');
const { add } = require('@repo/utils/math');
Both work seamlessly without dual installations.
TypeScript moduleResolution: bundler vs node16
TypeScript's moduleResolution affects how imports are resolved:
{
"compilerOptions": {
"moduleResolution": "bundler",
"module": "ESNext",
"target": "ES2022"
}
}
bundler (recommended for modern projects):
- Works with modern package exports
- Understands conditional exports
- Simulates bundler behavior
- Best for most projects in 2026
node16 (if you need strict Node.js compliance):
- Follows Node.js ESM/CJS resolution exactly
- More strict than
bundler - Requires explicit file extensions:
import { x } from './module.js'
// With "moduleResolution": "bundler"
import { sum } from '@repo/utils'; // ✓ Works
import { sum } from '@repo/utils/math'; // ✓ Works
// With "moduleResolution": "node16"
import { sum } from '@repo/utils/index.js'; // ✓ Requires extension
import { sum } from '@repo/utils/math.js'; // ✓ Requires extension
For most projects, use "moduleResolution": "bundler" and skip file extensions.
.mts and .cts File Extensions
Signal TypeScript files explicitly with extensions:
.ts→modulesetting in tsconfig.mts→ Always ESM.cts→ Always CommonJS
// api.mts (Always ESM, even if tsconfig says otherwise)
export async function fetchUser(id: number) {
return (await fetch(`/api/users/${id}`)).json();
}
// legacy.cts (Always CommonJS)
export = {
fetchUser: async (id: number) => {
return (await fetch(`/api/users/${id}`)).json();
},
};
// index.ts (Uses tsconfig.json "module" setting)
import { fetchUser } from './api.mts';
Use .mts and .cts to be explicit; use .ts for consistency within a single module type.
- Current State of ESM in Node.js 22
- require(esm) in Node 22 — The Missing Piece
- package.json exports Field for Dual Publishing
- TypeScript moduleResolution: bundler vs node16
- .mts and .cts File Extensions
- Dynamic import() for ESM from CommonJS
- Interop Pitfalls: Default Exports and Named Exports
- Building Libraries for Both ESM and CJS
- Tools: tsup, pkgroll, unbuild
- When to Just Go Full ESM
- ESM/CJS Resolution Flowchart
- Checklist
- Conclusion
Dynamic import() for ESM from CommonJS
Import ESM modules dynamically in CommonJS:
// legacy.cjs (CommonJS)
async function loadAPI() {
const { fetchUser } = await import('./api.mjs');
return fetchUser(1);
}
loadAPI().then(user => {
console.log(user);
});
import() always returns a Promise, even in ESM. It's the escape hatch for lazy loading.
Interop Pitfalls: Default Exports and Named Exports
The trickiest part of ESM/CJS interop is default exports:
// api.mjs (ESM with default export)
export default { fetchUser: async (id) => { /* ... */ } };
export function helper() { }
// Using from CommonJS
const api = require('./api.mjs'); // Default export
const { helper } = require('./api.mjs'); // Named export
// ✓ Both work
console.log(api.fetchUser); // function
console.log(helper); // function
But mixing is error-prone. Best practice: avoid default exports entirely.
// Better: Named exports only
// api.mjs
export async function fetchUser(id: number) { }
export async function fetchPost(id: number) { }
// CommonJS requires object destructuring
const { fetchUser, fetchPost } = require('./api.mjs');
// ESM also requires destructuring
import { fetchUser, fetchPost } from './api.mjs';
Named exports are explicit and work the same way in both systems.
Building Libraries for Both ESM and CJS
Use tsup or unbuild to output dual formats:
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: {
index: 'src/index.ts',
math: 'src/math.ts',
},
format: ['esm', 'cjs'],
dts: true,
splitting: false,
sourcemap: true,
clean: true,
});
This generates:
dist/
├── index.js (ESM)
├── index.cjs (CommonJS)
├── index.d.ts (Types)
├── math.js (ESM)
├── math.cjs (CommonJS)
└── math.d.ts (Types)
With package.json exports pointing to both formats, you get full compatibility.
Tools: tsup, pkgroll, unbuild
tsup (recommended):
npm install -D tsup
tsup src/index.ts --format esm,cjs --dts
pkgroll (minimal, zero config):
npm install -D pkgroll
pkgroll --dist dist src/index.ts
unbuild (powerful, for large projects):
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
entries: [
'src/index',
'src/cli',
],
declaration: true,
rollup: {
emitCJS: true,
},
});
All three solve the dual-publish problem. tsup is fastest for simple projects.
When to Just Go Full ESM
Consider going full ESM if:
- Your target is Node.js 18+ only
- No legacy CommonJS dependencies
- You control all consumers
- You're building a new library
{
"type": "module",
"engines": { "node": ">=22" },
"exports": {
".": "./dist/index.js"
}
}
In this case, drop CommonJS entirely. The ecosystem is moving ESM, and declaring "type": "module" is the clearest signal.
Don't go full ESM if:
- You have CommonJS dependencies
- Your library is widely used (unknown consumers)
- You need
require()for optional dependencies - You're in a large monorepo with mixed modules
ESM/CJS Resolution Flowchart
1. Is the importer ESM (.mjs, .js with "type": "module")?
✓ Yes: Use ESM resolution
- Try package exports (conditional "import" field)
- Fall back to package.json "main" field
- Allow top-level await
✗ No: Use CommonJS resolution
- Try package exports (conditional "require" field)
- Fall back to package.json "main" field
2. Can't resolve?
- Check file extensions (.mjs, .cjs, .js)
- Check "exports" conditions ("types", "import", "require", "default")
- Check subpath exports (package.json "exports.{subpath}")
3. Fallback:
- Use package.json "main" field (CommonJS-style)
- Last resort: index.js in package root
Checklist
- Update to Node.js 22 LTS
- Set
"type": "module"in package.json (if starting fresh) - Use
"moduleResolution": "bundler"in TypeScript - Define
"exports"field with conditionalimport/require - Avoid default exports; use named exports only
- Use
.mtsand.ctsfor explicit module types - Set up dual-format build with
tsup/unbuild - Test
require(esm)for legacy consumers - Document ESM/CJS support in README
- Remove
index.jsfallback if using explicit exports
Conclusion
ESM is now the standard in Node.js 22, and CommonJS interop is solved. The require(esm) feature, combined with explicit package.json exports, enables gradual migration and dual publishing without pain. For new projects, go full ESM. For legacy projects, use require(esm) and conditional exports to navigate both worlds.