Introduction
Node.js has supported ECMAScript Modules (ESM) since v12, but the ecosystem remains deeply rooted in CommonJS (CJS). Mixing the two module systems in a single project — or consuming CJS packages from ESM code — introduces subtle pitfalls around exports, default imports, and globals. This article maps the interop boundary and provides concrete strategies for a smooth migration.
CJS vs ESM at a Glance
| Aspect | CommonJS | ESM |
|---|---|---|
| File extensions | .js, .cjs | .js, .mjs |
Default in package.json | "type": "commonjs" | "type": "module" |
| Loading | Synchronous (require()) | Asynchronous (import) |
Top-level this | module.exports | undefined |
__dirname / __filename | Available | Not available |
| Live bindings | Copy exports | Live bindings |
Configuring the Module System
You control the module system via the package.json "type" field:
{
"type": "module"
}
- Files with
.mjsextension are always treated as ESM. - Files with
.cjsextension are always treated as CJS. - Files with
.jsextension default to what"type"specifies.
Importing CJS from ESM
Node.js allows ESM to import CJS modules. The CJS module.exports is mapped to the default export of the ESM import:
// cjs-module.js
module.exports = { hello: 'world' };
// esm-module.mjs
import cjsModule from './cjs-module.js';
console.log(cjsModule.hello); // "world"
Named Exports Quirk
Named exports from CJS are auto-detected via static analysis in Node.js — but with limitations. Dynamic or conditional exports may not resolve correctly:
// This works only if Node can statically analyze the exports
import { hello } from './cjs-module.js';
If you encounter export not found errors, fall back to importing the entire default:
import cjsModule from './cjs-module.js';
const { hello } = cjsModule;
Importing ESM from CJS
CJS cannot require() ESM modules. You must use dynamic import(), which returns a promise:
// cjs-file.cjs
async function loadEsm() {
const esmModule = await import('./esm-module.mjs');
console.log(esmModule.namedExport);
}
This is especially relevant when writing config files (e.g., webpack, ESLint) that remain in CJS but need to load ESM plugins.
__dirname Alternatives in ESM
CJS provides __dirname and __filename globals. ESM replaces them with import.meta:
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
For a more concise approach, use the import.meta.resolve API (experimental in older Node versions):
const resolvedPath = import.meta.resolve('./relative-path.js');
Key Interop Pitfalls
1. Default Export Confusion
CJS exports a single object — there is no true “default” vs “named.” When you write:
// CJS
exports.default = foo;
exports.named = bar;
ESM interprets this as:
import { default as foo, named as bar } from './cjs.js';
But import cjs from './cjs.js' binds to module.exports, which may not be what the CJS author intended.
2. JSON Modules
In CJS, require('./data.json') works synchronously. In ESM, you need an import assertion:
import data from './data.json' with { type: 'json' };
3. Top-Level Await
ESM supports top-level await; CJS does not. If you need top-level await in a CJS context, wrap your code in an async IIFE.
Migration Strategy
| Step | Action |
|---|---|
| 1 | Add "type": "module" to package.json |
| 2 | Rename files to .mjs or update imports to use full extensions |
| 3 | Replace require() with import — use dynamic import() for CJS files |
| 4 | Replace __dirname / __filename with import.meta.url patterns |
| 5 | Update any dependency that cannot be imported to await import() |
For large codebases, consider a dual-package approach: ship both CJS and ESM builds using "exports" in package.json:
{
"exports": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
Conclusion
ESM is the future of Node.js module systems, but the transition from CJS requires careful handling of interop boundaries. Understanding how default exports map, when to use dynamic import(), and how to replace CJS globals with import.meta.url will save you from runtime surprises. A phased migration, combined with dual-package exports, allows you to support both systems without breaking existing consumers.
