Modules: ESM and CommonJS
Two module systems
Section titled “Two module systems”Node.js supports two module systems that cannot be freely mixed:
| Feature | ESM | CommonJS |
|---|---|---|
| Syntax | import / export | require() / module.exports |
| File extension | .mjs or .js with "type":"module" | .cjs or .js without "type":"module" |
| Loading | Static, analysed at parse time | Dynamic, executed at runtime |
Top-level await | Supported | Not supported |
| Default in Node 24+ | Yes, with "type": "module" — and CJS can require() it | Legacy default |
CommonJS (CJS)
Section titled “CommonJS (CJS)”CommonJS was the original Node.js module system. It is still widely used in older packages and tooling.
// @skip-verify multi-file: illustrative// math.js (CommonJS)function add(a, b) { return a + b; }function multiply(a, b) { return a * b; }
module.exports = { add, multiply }; // named exports via object// OR: module.exports = add; // single default export
// main.js (CommonJS)const { add, multiply } = require('./math');console.log(add(2, 3)); // 5console.log(multiply(4, 5)); // 20require() is synchronous and loads the file immediately when the line is executed. This means you can require() inside an if block or a function.
ECMAScript Modules (ESM)
Section titled “ECMAScript Modules (ESM)”ESM is the official JavaScript module standard. It is now the recommended system for new Node.js projects.
// @skip-verify multi-file: illustrative// math.mjs (ESM — named exports)export function add(a, b) { return a + b; }export function multiply(a, b) { return a * b; }
// utils.mjs (ESM — default export)export default function formatNumber(n) { return n.toFixed(2);}
// main.mjs (ESM — named import)import { add, multiply } from './math.mjs';import formatNumber from './utils.mjs';
console.log(add(2, 3)); // 5console.log(formatNumber(3.14159)); // '3.14'import statements are static — they must appear at the top of the file and are resolved before any code runs. This enables tree-shaking (dead-code elimination) by bundlers.
Enabling ESM in Node.js
Section titled “Enabling ESM in Node.js”Two ways to tell Node.js a file uses ESM:
"type": "module"inpackage.json— all.jsfiles in that package are treated as ESM. Use.cjsfor any file that must stay CommonJS..mjsfile extension — always treated as ESM regardless ofpackage.json.
{ "name": "my-app", "type": "module"}Named vs default exports
Section titled “Named vs default exports”// @skip-verify multi-file: illustrative// Named: export multiple bindings, import with exact names (or rename)export const PI = 3.14159;export function area(r) { return PI * r * r; }
import { PI, area } from './circle.mjs';import { area as circleArea } from './circle.mjs'; // rename
// Default: one main export per file, imported with any nameexport default class Logger { /* ... */ }
import Logger from './logger.mjs';import MyLogger from './logger.mjs'; // same thing, different local nameRunnable ESM demo
Section titled “Runnable ESM demo”This snippet runs in the Node.js runtime (module resolution requires Node). It demonstrates how an ES module with named exports works end-to-end using inline dynamic import().
// ESM inline via data: URL — requires Node runtimeconst src = ` export function add(a, b) { return a + b; } export const VERSION = '1.0.0';`;const mod = await import('data:text/javascript,' + encodeURIComponent(src));console.log('add(3, 4):', mod.add(3, 4));console.log('VERSION:', mod.VERSION);Needs the Node.js runtime — open in StackBlitz to run.
CJS/ESM interop cheat sheet
Section titled “CJS/ESM interop cheat sheet”Real Node.js codebases mix old CommonJS packages with new ESM code constantly, so the interop rules matter even in an “all-ESM” project the moment it depends on an older package. The full detail — including the module resolution algorithm and the exports map — lives in Module Resolution; this is the cheat sheet for what you will actually trip over while writing application code.
| Question | Answer on Node 24 |
|---|---|
Can CJS require() an ESM file? | Yes, by default — unless the ESM file uses top-level await, which throws ERR_REQUIRE_ASYNC_MODULE |
Does __dirname exist in an ESM file? | No — referencing it throws ReferenceError. Use import.meta.dirname instead |
Do import bindings update when the source module reassigns them? | Yes — ESM imports are live bindings, not copies |
Do require()-destructured values update the same way? | No — CJS gives you a snapshot of the value at the moment require() ran |
What does import x from './cjs-file.cjs' see when module.exports = someValue? | someValue, as the default export |
Does a plain .js file need "type": "module" to use import/export? | On Node 24, Node can auto-detect ESM syntax and run the file as a module even without the field (detect-module) — but an explicit "type" is still the clearer, faster-resolving choice |
flowchart TD
A["require('./file.mjs')"] --> B{"Does file.mjs use top-level await?"}
B -->|"no"| C["Works — module.exports-style access to the ESM's exports"]
B -->|"yes"| D["Throws ERR_REQUIRE_ASYNC_MODULE"]
D --> E["Fix: use dynamic import('./file.mjs') instead, awaited inside an async function"] Output (verified on Node 24)
require(sync esm).add(2,3): 5
require(async esm) threw: ERR_REQUIRE_ASYNC_MODULEThe next demo writes one .mjs sibling and two .cjs siblings to show live bindings and the default-export interop side by side, then removes all three files it created.
Output (verified on Node 24)
esm import counter before inc(): 0
esm import counter after inc(): 1
cjs require counter before inc(): 0
cjs require counter after inc() (still the old copy): 0
cjs module.exports as import default: Hi, Danacounter in the ESM sibling is exported as a live binding: the importing side reads the module’s actual variable, so it sees 1 after inc() runs. The CJS sibling’s { counter, inc } object is destructured — counter at that point is copied out as a plain number, so nothing the sibling does afterwards can change the local counter variable in the importer.
Next: continue this cheat sheet in Module Resolution, which covers how Node finds a specifier on disk in the first place — node_modules lookup, the exports map, and condition ordering.
Mental Model
Section titled “Mental Model”Think of CJS and ESM as two different postal systems that happen to deliver to the same mailbox (the file on disk). CJS delivers a sealed snapshot — whatever module.exports looked like the instant require() ran, copied out; nothing that arrives later updates it. ESM delivers a live wire to the source module’s own variables — a import { x } from './m.js' binding is not a copy of x, it is a read-only view onto the exporter’s actual x, which is why it updates when the exporter does. __dirname and import.meta.dirname are two different systems for the same underlying question (“what folder is this file in”), and only the ESM one is spelled import.meta.dirname.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
CommonJS (require) | Simple, synchronous, dynamic (require() inside an if); every existing Node package still works | No top-level await; snapshot exports, not live; needs createRequire tricks for import.meta-only features |
ESM (import/export) | Standard JS module system; static analysis and tree-shaking; live bindings; top-level await | Cannot require() an ESM file with top-level await; __dirname/__filename do not exist |
"type": "module" in package.json | Explicit, fast — Node does not need to sniff file contents | Every .js file in that package must then be valid ESM (use .cjs for the exceptions) |
Relying on detect-module (no "type" field) | Works today for ambiguous .js files | Slower to resolve (Node must inspect syntax) and less obvious to a reader what module system a file uses |
Common Misconceptions
Section titled “Common Misconceptions”- “You can never
require()an ESM module.” You usually can — the exception is specifically an ESM file that uses top-levelawait, which throwsERR_REQUIRE_ASYNC_MODULEbecauserequire()must return synchronously. - “
__dirnameisundefinedin an ES module.” It does not exist at all — referencing it throws aReferenceError, not “gives youundefined”. The direct replacement isimport.meta.dirname. - “Importing a CommonJS module from ESM gives you live bindings too.” No — CJS modules never have live bindings, on either side. Live bindings are strictly an ESM-to-ESM feature; importing CJS from ESM still gets a one-time snapshot (wrapped so
module.exportsappears asdefault).
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated Node.js snippets are frequently written from older training data that assumes a pure-CommonJS world, so they mix require() and import/export in the same file, or use __dirname inside a file whose package.json says "type": "module". Both are immediate, loud failures — but only once the file actually runs, not at a casual read.
🐛 Spot the bug
import fs from 'node:fs';
const configPath = __dirname + '/config.json';const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
console.log(config);Show the answer
This file uses import, so it is an ES module — and __dirname simply does not exist in ES modules; referencing it throws ReferenceError: __dirname is not defined, not a silent undefined. The fix is import.meta.dirname (Node 20.11+, no flag needed): const configPath = import.meta.dirname + '/config.json';. If the code needs to support older Node versions too, the portable form is path.dirname(fileURLToPath(import.meta.url)).