Skip to content

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

math.cjs
function add(a, b) { return a + b; }
function sub(a, b) { return a - b; }
module.exports = { add, sub };
app.cjs
const { add } = require('./math.cjs');
console.log(add(2, 3)); // => 5

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 object
module.exports.b = 2; // works — same object
exports = { c: 3 }; // does NOTHING outside this file
module.exports = { c: 3 }; // works — replaces the exported value

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/function declarations are not global — they are local to that wrapper function.
  • require, module, exports, __filename, and __dirname are not globals either; they are parameters, which is why they do not exist in ES modules.
  • this at the top level of a CJS module is module.exports, not globalThis.
wrapper-demo.cjs
console.log(__filename); // /home/me/project/wrapper-demo.cjs
console.log(__dirname); // /home/me/project
console.log(this === module.exports); // => true

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.

counter.cjs
let n = 0;
module.exports = { bump: () => ++n };
cache.cjs
const a = require('./counter.cjs');
const b = require('./counter.cjs');
console.log(a === b); // => true — same object
a.bump(); a.bump();
console.log(b.bump()); // => 3 — shared state

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

a.cjs
exports.name = 'a';
const b = require('./b.cjs');
console.log('in a, b.name =', b.name);
b.cjs
const a = require('./a.cjs');
console.log('in b, a.name =', a.name); // => 'a' — set before the require above
exports.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.

Given require(X) from a file in directory Y, Node resolves in this order:

  1. Core module — if X is fs, path, node:fs, etc., return the built-in immediately. The node: prefix (e.g. require('node:fs')) is unambiguous and cannot be shadowed by a package; prefer it.
  2. Relative or absolute path — if X starts with ./, ../, or /, resolve it against Y and try, in order:
    • the exact file X
    • X.js, X.json, X.node
    • X/ as a directory: its package.json main field, else X/index.js, X/index.json, X/index.node
  3. Bare specifier — anything else (react, lodash/merge) is a package name. Node walks up the directory tree looking for node_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/lodash

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

/home/me/project/node_modules/lodash/lodash.js
console.log(require.resolve('lodash'));

ESM is the language’s own module system: import and export, statically analysable, asynchronous, and the same syntax the browser uses.

math.js
export function add(a, b) { return a + b; }
export function sub(a, b) { return a - b; }
export default { add, sub };
app.js
import { add } from './math.js';
import defaults, { sub as subtract } from './math.js';
import * as math from './math.js';
console.log(add(2, 3)); // => 5

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
package.json
{
"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"}.

Extensions are mandatory in relative specifiers.

import './utils'; // ✗ ERR_MODULE_NOT_FOUND
import './utils.js'; // ✓
import './utils/index.js'; // ✓ — no directory-index magic either

Bare 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'; // ✗ SyntaxError
const mod = await import('./a.js'); // ✓ dynamic import, returns a Promise

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

state.js
export let count = 0;
export const bump = () => count++;
read.js
import { count, bump } from './state.js';
console.log(count); // 0
bump();
console.log(count); // 1 — the binding updated
// count = 5; // ✗ TypeError: Assignment to constant variable — imports are read-only

The replacement for __dirname and friends:

paths.js
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.js
console.log(import.meta.dirname); // /home/me/project
// Portable equivalent for older versions:
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

ES modules can await at the top level. The importing module waits for them.

config.js
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.

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.

import lodash from 'lodash'; // default = module.exports
import { merge } from 'lodash'; // often works, sometimes not

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

require() of an ES module used to throw ERR_REQUIRE_ESM. The portable answer is dynamic import:

legacy.cjs
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.

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.

Modern packages declare their entry points explicitly instead of relying on main:

package.json
{
"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:

  • exports is an allowlist. Once present, import 'my-lib/dist/internal.js' fails with ERR_PACKAGE_PATH_NOT_EXPORTED unless 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. types must come first; a bare default should come last.

The mirror image: private aliases for your own internals, resolvable from anywhere in your package without ../../../.

package.json
{
"imports": {
"#db": "./src/db/index.js",
"#config": {
"development": "./src/config.dev.js",
"default": "./src/config.js"
}
}
}
import { query } from '#db';
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.
Terminal window
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.
  • .js is CommonJS unless the nearest package.json says "type": "module". .mjs and .cjs override 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 __dirname with import.meta.dirname.
  • await import() is the universal escape hatch in both directions.
  • exports in package.json is an allowlist that defines your package’s real public API.