Skip to content

Modules: ESM and CommonJS

Node.js supports two module systems that cannot be freely mixed:

FeatureESMCommonJS
Syntaximport / exportrequire() / module.exports
File extension.mjs or .js with "type":"module".cjs or .js without "type":"module"
LoadingStatic, analysed at parse timeDynamic, executed at runtime
Top-level awaitSupportedNot supported
Default in Node 24+Yes, with "type": "module" — and CJS can require() itLegacy default

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)); // 5
console.log(multiply(4, 5)); // 20

require() is synchronous and loads the file immediately when the line is executed. This means you can require() inside an if block or a function.

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)); // 5
console.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.

Two ways to tell Node.js a file uses ESM:

  1. "type": "module" in package.json — all .js files in that package are treated as ESM. Use .cjs for any file that must stay CommonJS.
  2. .mjs file extension — always treated as ESM regardless of package.json.
{
"name": "my-app",
"type": "module"
}
// @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 name
export default class Logger { /* ... */ }
import Logger from './logger.mjs';
import MyLogger from './logger.mjs'; // same thing, different local name

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 runtime
const 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);
Node.js

Needs the Node.js runtime — open in StackBlitz to run.

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.

QuestionAnswer 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"]
require() of an ESM file works unless the file has a top-level await
Node.js
Output (verified on Node 24)
require(sync esm).add(2,3): 5
require(async esm) threw: ERR_REQUIRE_ASYNC_MODULE

The 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.

Node.js
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, Dana

counter 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.

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.

ChoiceBenefitCost
CommonJS (require)Simple, synchronous, dynamic (require() inside an if); every existing Node package still worksNo 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 awaitCannot require() an ESM file with top-level await; __dirname/__filename do not exist
"type": "module" in package.jsonExplicit, fast — Node does not need to sniff file contentsEvery .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 filesSlower to resolve (Node must inspect syntax) and less obvious to a reader what module system a file uses
  • “You can never require() an ESM module.” You usually can — the exception is specifically an ESM file that uses top-level await, which throws ERR_REQUIRE_ASYNC_MODULE because require() must return synchronously.
  • “__dirname is undefined in an ES module.” It does not exist at all — referencing it throws a ReferenceError, not “gives you undefined”. The direct replacement is import.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.exports appears as default).

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)).

What does adding "type": "module" to package.json do?
Which statement about import is true compared to require()?
How do you export a single main value from an ESM file?
What does this print?
// counter.mjs
export let counter = 0;
export function inc() { counter++; }

// main.mjs
import { counter, inc } from './counter.mjs';
console.log(counter);
inc();
console.log(counter);
What happens when this runs?
// utils.js — package.json has "type": "module"
import path from 'node:path';
console.log(path.join(__dirname, 'data.json'));