How We Ended Up With Two
JavaScript had no module system for its first fifteen years. Node shipped in 2009 and needed one immediately, so it adopted CommonJS — require() and module.exports. It worked, the entire npm ecosystem was built on it, and it is still everywhere.
Then ES2015 gave the language an official module system: import and export. Browsers implemented it. Node had to support it too — but could not break millions of existing CommonJS packages. So it supports both, side by side, with rules for which is which.
The deep reason they cannot merge cleanly: CommonJS is synchronous and dynamic — require() runs at the moment it is reached and returns a value. ES modules are asynchronous and static — imports are resolved and linked before any code runs. You can wrap a synchronous thing in an asynchronous one, but not the other way round.
CommonJS
// math.cjs
function add(a, b) { return a + b; }
function subtract(a, b) { return a - b; }
module.exports = { add, subtract };
// or
exports.add = add;
// or a single default-style export
module.exports = add;// app.cjs
const { add, subtract } = require('./math.cjs');
const fs = require('fs');
const express = require('express');
// Dynamic — a require can be conditional, or built from a variable
if (process.env.FEATURE_X) {
const featureX = require('./feature-x.cjs');
}
const plugin = require(`./plugins/${name}.cjs`);CommonJS gives you five free variables in every file: require, module, exports, __filename and __dirname. Node provides them by wrapping your file in a function before executing it — which is also why top-level this in CommonJS is module.exports rather than undefined.
ES Modules
// math.mjs
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
export const PI = 3.14159;
export default add; // one default export per module// app.mjs
import add, { subtract, PI } from './math.mjs'; // note the file extension
import fs from 'node:fs/promises';
import express from 'express';
// Namespace import
import * as math from './math.mjs';
// Dynamic import — returns a promise, allowed anywhere
const { default: featureX } = await import('./feature-x.mjs');
// Top-level await works — no wrapper function needed
const config = JSON.parse(await fs.readFile('config.json', 'utf8'));Two things stand out for anyone coming from bundled front-end code:
- File extensions are mandatory.
import './math.js', notimport './math'. Webpack and Vite let you omit them; Node does not. - Top-level
awaitis allowed. This is a genuine capability CommonJS cannot offer.
How Node Decides Which One
Node applies these rules in order:
| Condition | Treated as |
|---|---|
File ends in .mjs | ES module |
File ends in .cjs | CommonJS |
File ends in .js and nearest package.json has "type": "module" | ES module |
File ends in .js and "type" is "commonjs" or absent | CommonJS |
{
"name": "my-app",
"type": "module"
}That one line switches every .js file in the project to ESM. You can still opt individual files back out by naming them .cjs.
“Nearest package.json” means Node walks up from the file. A subdirectory can carry its own package.json containing only {"type": "commonjs"} — a useful trick for keeping one legacy folder on CJS inside an ESM project.
The Differences That Bite
| CommonJS | ES Modules | |
|---|---|---|
| Syntax | require / module.exports | import / export |
| Loading | Synchronous, at runtime | Asynchronous, resolved before execution |
| File extension in path | Optional | Required |
| Directory index | require('./utils') finds utils/index.js | Not resolved — be explicit |
Top-level await | No | Yes |
__dirname, __filename | Available | Not defined |
this at top level | module.exports | undefined |
| Bindings | A copied value | A live binding |
| Conditional loading | Yes — require anywhere | Only via dynamic import() |
| JSON import | require('./x.json') just works | Needs an import attribute |
The live binding difference is subtle and occasionally important:
// counter.mjs
export let count = 0;
export function increment() { count++; }// ESM — you see the updated value
import { count, increment } from './counter.mjs';
console.log(count); // 0
increment();
console.log(count); // 1 <- live binding
// CommonJS equivalent — you captured a copy
const { count, increment } = require('./counter.cjs');
console.log(count); // 0
increment();
console.log(count); // 0 <- still the old copyAnd JSON, which trips up almost everyone migrating:
// CommonJS
const pkg = require('./package.json');
// ESM — import attributes (Node 20.10+)
import pkg from './package.json' with { type: 'json' };
// Or just read it, which always works
import { readFile } from 'node:fs/promises';
const pkg = JSON.parse(await readFile('./package.json', 'utf8'));Interop Between the Two
The rules are asymmetric, and that asymmetry is the whole problem.
ESM importing CommonJS — works. The entire module.exports object arrives as the default export:
// Importing a CommonJS package from an ES module
import express from 'express'; // fine — express is CJS
import pkg from 'some-cjs-package';
const { helper } = pkg; // named imports may not be detected
// Node can often detect named exports statically, but not always:
import { helper } from 'some-cjs-package'; // may throw SyntaxErrorCommonJS requiring ESM — historically impossible. This is the source of the infamous error:
const chalk = require('chalk');
// Error [ERR_REQUIRE_ESM]: require() of ES Module .../chalk/source/index.js
// not supported. Instead change the require to a dynamic import().The workaround has always been dynamic import(), which returns a promise and therefore works from CommonJS:
// In a CommonJS file
async function main() {
const { default: chalk } = await import('chalk');
console.log(chalk.green('It works'));
}
main();Node 22 added require(esm) for ES modules that have no top-level await, and it is enabled by default from Node 22.12. That removes most of the pain — but a module using top-level await still cannot be required, because require is synchronous and there is nowhere to wait.
Decoding the Error Messages
| Error | Cause | Fix |
|---|---|---|
ERR_REQUIRE_ESM | require() on an ESM package | Use await import(), or move the file to ESM, or upgrade to Node 22.12+ |
Cannot use import statement outside a module | import in a file Node treats as CJS | Add "type": "module", or rename to .mjs |
require is not defined in ES module scope | require() in an ESM file | Convert to import, or rename the file to .cjs |
ERR_MODULE_NOT_FOUND | Missing file extension in an ESM import | Add .js to the specifier |
__dirname is not defined | CJS global used in ESM | See the next section |
Named export not found | Named import from a CJS module Node could not analyse | Import the default, then destructure |
ERR_UNSUPPORTED_DIR_IMPORT | import './utils' pointing at a folder | Import './utils/index.js' explicitly |
__dirname and Friends in ESM
// ESM equivalents
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Node 20.11+ / 21.2+ gives you these directly
const dir = import.meta.dirname;
const file = import.meta.filename;
// Resolve a path relative to the current module
const configPath = path.join(import.meta.dirname, 'config.json');
// "Is this file being run directly?" — the ESM version of require.main === module
if (process.argv[1] === import.meta.filename) {
main();
}import.meta.dirname is the modern answer and works from Node 20.11. Use it and skip the fileURLToPath dance entirely unless you must support older runtimes.
Migrating a Project
An incremental path that does not require a big-bang rewrite:
- Check your Node version. Node 20+ minimum; 22.12+ makes interop far easier.
- Rename every existing
.jsfile to.cjsand confirm the app still runs. Nothing has changed semantically yet. - Add
"type": "module"topackage.json. New.jsfiles are now ESM; the.cjsones keep working. - Convert file by file, starting at the leaves — utilities with no dependencies of their own — and work up toward the entry point.
- For each file: swap
requireforimport, add file extensions, swapmodule.exportsforexport, and replace__dirname. - Update the entry point last, then delete any remaining
.cjsfiles.
// Before (CJS)
const path = require('path');
const { helper } = require('./utils');
module.exports = { doThing };
// After (ESM)
import path from 'node:path';
import { helper } from './utils/index.js'; // extension required
export { doThing };If you publish a package, you can ship both builds and let consumers pick via exports:
{
"name": "my-lib",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./package.json": "./package.json"
}
}A dual package can load twice in one process — once as CJS and once as ESM — giving you two separate copies of any module state. If your library holds a singleton, a cache or a class used with instanceof, that is a real bug. Keeping the stateful core in a single CJS file that both builds import is the usual workaround.
What to Use in 2026
- New project: ESM. Add
"type": "module"on day one. It is the language standard, it matches what you already write on the front end, and top-levelawaitis genuinely useful. - Existing CommonJS project that works: leave it. There is no runtime benefit to migrating, and CJS is not going away. Migrate when you have another reason to touch the files.
- Publishing a library: ship ESM, and add a CJS build if your users need it. Test both entry points in CI.
- Using TypeScript: set
"module": "NodeNext"and"moduleResolution": "NodeNext"intsconfig.json. It enforces the extension rules at compile time, which catches these errors before you run anything.
The two systems will coexist for years. Knowing which one a file is, and why, is most of what you need — nearly every error in this article is Node telling you it disagrees with your assumption about that.