Modules — CommonJS and ESM
Node has two module systems. CommonJS (require) predates the JavaScript standard and is synchronous. ES modules (import) are the standard and are asynchronous. Both are fully supported, they follow different rules, and mixing them is where most Node confusion lives.
CommonJS
Section titled “CommonJS”CommonJS — CJS — shipped with Node in 2009, years before JavaScript had modules of its own. Every .js file is a module with its own scope; you pull in other modules with require() and expose things by assigning to module.exports.
function add(a, b) { return a + b; }function sub(a, b) { return a - b; }
module.exports = { add, sub };const { add } = require('./math.cjs');console.log(add(2, 3)); // => 5module.exports vs exports
Section titled “module.exports vs exports”exports is just a variable that starts out pointing at the same object as module.exports. Only module.exports is what actually gets returned.
exports.a = 1; // works — mutates the shared objectmodule.exports.b = 2; // works — same object
exports = { c: 3 }; // does NOTHING outside this filemodule.exports = { c: 3 }; // works — replaces the exported valueThe module wrapper
Section titled “The module wrapper”Before executing a CommonJS file, Node wraps its source in a function:
(function (exports, require, module, __filename, __dirname) { // your file's code lives here});This explains several things at once:
- Top-level
var/functiondeclarations are not global — they are local to that wrapper function. require,module,exports,__filename, and__dirnameare not globals either; they are parameters, which is why they do not exist in ES modules.thisat the top level of a CJS module ismodule.exports, notglobalThis.
console.log(__filename); // /home/me/project/wrapper-demo.cjsconsole.log(__dirname); // /home/me/projectconsole.log(this === module.exports); // => trueLoading is synchronous and cached
Section titled “Loading is synchronous and cached”require() reads the file from disk, compiles it, and runs it — all before returning. Every subsequent require() of the same resolved path returns the same cached module.exports object without re-running the file.
let n = 0;module.exports = { bump: () => ++n };const a = require('./counter.cjs');const b = require('./counter.cjs');console.log(a === b); // => true — same objecta.bump(); a.bump();console.log(b.bump()); // => 3 — shared stateThe cache lives in require.cache, keyed by absolute path. Deleting entries to force a reload is a well-known hack; it is fine in a dev script and a source of subtle bugs in production.
Because loading is synchronous, circular requires do not deadlock — they return a partial object:
exports.name = 'a';const b = require('./b.cjs');console.log('in a, b.name =', b.name);const a = require('./a.cjs');console.log('in b, a.name =', a.name); // => 'a' — set before the require aboveexports.name = 'b';If a.cjs had required b before setting exports.name, b would have seen undefined. Circular dependencies work by accident, not by design — restructure instead.
The resolution algorithm
Section titled “The resolution algorithm”Given require(X) from a file in directory Y, Node resolves in this order:
- Core module — if
Xisfs,path,node:fs, etc., return the built-in immediately. Thenode:prefix (e.g.require('node:fs')) is unambiguous and cannot be shadowed by a package; prefer it. - Relative or absolute path — if
Xstarts with./,../, or/, resolve it againstYand try, in order:- the exact file
X X.js,X.json,X.nodeX/as a directory: itspackage.jsonmainfield, elseX/index.js,X/index.json,X/index.node
- the exact file
- Bare specifier — anything else (
react,lodash/merge) is a package name. Node walks up the directory tree looking fornode_modules:
/home/me/project/src/api/routes.js requires 'lodash' → /home/me/project/src/api/node_modules/lodash → /home/me/project/src/node_modules/lodash → /home/me/project/node_modules/lodash ← found → /home/me/node_modules/lodash → /home/node_modules/lodash → /node_modules/lodashThe first match wins. This upward walk is why a dependency deep inside node_modules can still resolve a package hoisted to the root — and why “phantom dependencies” happen, covered in npm vs pnpm.
You can inspect resolution yourself:
console.log(require.resolve('lodash'));ES modules in Node
Section titled “ES modules in Node”ESM is the language’s own module system: import and export, statically analysable, asynchronous, and the same syntax the browser uses.
export function add(a, b) { return a + b; }export function sub(a, b) { return a - b; }export default { add, sub };import { add } from './math.js';import defaults, { sub as subtract } from './math.js';import * as math from './math.js';
console.log(add(2, 3)); // => 5Telling Node a file is ESM
Section titled “Telling Node a file is ESM”Node decides per file, using two signals:
| Signal | Result |
|---|---|
Nearest package.json has "type": "module" |
.js files are ESM |
Nearest package.json has "type": "commonjs" or no type |
.js files are CommonJS |
File extension .mjs |
always ESM, regardless of type |
File extension .cjs |
always CommonJS, regardless of type |
{ "name": "my-app", "type": "module"}“Nearest package.json” means: walk up from the file until you find one. This is per-directory, so a subdirectory can opt out with its own {"type": "commonjs"}.
Rules that differ from CommonJS
Section titled “Rules that differ from CommonJS”Extensions are mandatory in relative specifiers.
import './utils'; // ✗ ERR_MODULE_NOT_FOUNDimport './utils.js'; // ✓import './utils/index.js'; // ✓ — no directory-index magic eitherBare package specifiers (import x from 'lodash') still resolve without an extension, because the package’s own exports/main field says what to load.
No require, __dirname, __filename, module, or exports. They were wrapper parameters, and there is no wrapper.
Imports are hoisted and evaluated before any of your code runs. This is static: the specifier must be a string literal, and you cannot conditionally import at the top level.
if (flag) import './a.js'; // ✗ SyntaxErrorconst mod = await import('./a.js'); // ✓ dynamic import, returns a Promiseimport() works in CommonJS files too, and is the standard way for CJS code to load an ESM package.
Bindings are live, not copies. An imported name tracks the exporting module’s variable.
export let count = 0;export const bump = () => count++;import { count, bump } from './state.js';console.log(count); // 0bump();console.log(count); // 1 — the binding updated// count = 5; // ✗ TypeError: Assignment to constant variable — imports are read-onlyimport.meta
Section titled “import.meta”The replacement for __dirname and friends:
import { fileURLToPath } from 'node:url';import path from 'node:path';
console.log(import.meta.url);// file:///home/me/project/paths.js — always a URL, not a path
// Node 20.11+ / 21.2+ give you these directly:console.log(import.meta.filename); // /home/me/project/paths.jsconsole.log(import.meta.dirname); // /home/me/project
// Portable equivalent for older versions:const __filename = fileURLToPath(import.meta.url);const __dirname = path.dirname(__filename);Top-level await
Section titled “Top-level await”ES modules can await at the top level. The importing module waits for them.
import { readFile } from 'node:fs/promises';
export const config = JSON.parse( await readFile(new URL('./config.json', import.meta.url), 'utf8'));This is impossible in CommonJS and is one of the better reasons to move. The cost: a module doing slow top-level await delays everything that imports it, and it makes the module un-require-able from CJS.
Importing JSON
Section titled “Importing JSON”JSON needs an explicit import attribute in ESM:
import pkg from './package.json' with { type: 'json' };console.log(pkg.version);The with { type: 'json' } syntax is supported from Node 20.10+ / 22 (earlier versions used the now-removed assert keyword). In CommonJS, require('./package.json') just works.
Interop
Section titled “Interop”CJS from ESM — usually fine
Section titled “CJS from ESM — usually fine”import lodash from 'lodash'; // default = module.exportsimport { merge } from 'lodash'; // often works, sometimes notNode runs a static analyser (cjs-module-lexer) over CommonJS files to guess their named exports. It handles the common patterns (exports.foo = …, module.exports = { foo }) and gives up on dynamic ones. When named imports fail, fall back to the default import:
import pkg from 'some-cjs-lib';const { thing } = pkg;There is no import { … } for a CJS module built at runtime, because ESM named imports are resolved before the CJS module executes.
ESM from CJS — historically painful
Section titled “ESM from CJS — historically painful”require() of an ES module used to throw ERR_REQUIRE_ESM. The portable answer is dynamic import:
async function main() { const { default: chalk } = await import('chalk'); // chalk 5+ is ESM-only console.log(chalk.green('ok'));}main();Node 22.12+ (and Node 23+) can require() an ES module if it has no top-level await — the call returns the module namespace object. Do not rely on it in a library that must support older LTS lines; await import() works everywhere.
The dual package hazard
Section titled “The dual package hazard”A package that ships both a CJS and an ESM build can end up loaded twice in one process — once through each entry point — giving you two copies of its module state. If one copy holds a registry, a cache, or instanceof-checked classes, comparisons silently fail.
Mitigations: publish ESM-only, or keep all state in a small CJS core that the ESM wrapper re-exports.
The exports field
Section titled “The exports field”Modern packages declare their entry points explicitly instead of relying on main:
{ "name": "my-lib", "type": "module", "main": "./dist/index.cjs", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" }, "./utils": "./dist/utils.js", "./package.json": "./package.json" }}Two consequences worth knowing:
exportsis an allowlist. Once present,import 'my-lib/dist/internal.js'fails withERR_PACKAGE_PATH_NOT_EXPORTEDunless it is listed. This is a feature — it makes your package’s public surface real — and a common upgrade break.- Condition order matters. Node picks the first matching key top-to-bottom.
typesmust come first; a baredefaultshould come last.
Subpath imports (#-prefixed)
Section titled “Subpath imports (#-prefixed)”The mirror image: private aliases for your own internals, resolvable from anywhere in your package without ../../../.
{ "imports": { "#db": "./src/db/index.js", "#config": { "development": "./src/config.dev.js", "default": "./src/config.js" } }}import { query } from '#db';Which one will you actually meet?
Section titled “Which one will you actually meet?”| Context | Reality |
|---|---|
| New applications | ESM. Set "type": "module". |
| New libraries | ESM, sometimes dual-published for consumers stuck on CJS. |
| Older Node codebases | CommonJS everywhere, often mid-migration. |
| Config files for tools | Mixed; many tools accept .config.js, .config.mjs, and .config.cjs precisely because of this. |
| TypeScript output | Whatever module in tsconfig.json says — the source syntax alone does not decide. |
Debugging module problems
Section titled “Debugging module problems”node --input-type=module -e "import('lodash').then(m => console.log(Object.keys(m)))"Common errors and what they mean:
| Error | Cause |
|---|---|
ERR_REQUIRE_ESM |
You require()d an ESM-only package. Use await import(). |
Cannot use import statement outside a module |
The file was treated as CommonJS. Add "type": "module" or rename to .mjs. |
ERR_MODULE_NOT_FOUND on a relative path |
Missing file extension in an ESM import. |
ERR_PACKAGE_PATH_NOT_EXPORTED |
The subpath is not listed in the package’s exports. |
SyntaxError: The requested module … does not provide an export named 'X' |
Named-export detection failed on a CJS module. Import the default and destructure. |
__dirname is not defined |
You are in ESM. Use import.meta.dirname. |
Key points
Section titled “Key points”.jsis CommonJS unless the nearestpackage.jsonsays"type": "module"..mjsand.cjsoverride that.- CommonJS is synchronous, cached by absolute path, and wrapped in a function that supplies
require,module,exports,__dirname,__filename. - Bare specifiers resolve by walking up parent directories looking for
node_modules. - ESM requires file extensions on relative imports, has live read-only bindings, supports top-level
await, and replaces__dirnamewithimport.meta.dirname. await import()is the universal escape hatch in both directions.exportsinpackage.jsonis an allowlist that defines your package’s real public API.