CommonJS Modules

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; all versions and stability levels were checked on 2026-10-03 and every example below was run on all three lines. CommonJS (CJS) is the module system Node.js shipped with, and the one behind most of the code in node_modules. The page "Modules: CommonJS modules" is Stability 2 Stable, and so are require(), module, exports, filename, dirname, require.resolve(), require.cache and require.main. The exceptions are called out where they appear: folders as modules are Stability 3 Legacy, require.extensions and module.parent are Stability 0 Deprecated, package maps are Stability 1 Experimental, and importing native add-ons with import is Stability 1.2 Release candidate. Even if every new file in the Bookshelf application that grows across this section is written as an ES module (see ECMAScript Modules), you will keep meeting CommonJS: in dependencies, in older code bases, in tools, and in the error messages that mention require. The syntax overview lives in the JavaScript modules page; this page owns how Node.js loads, resolves and caches CommonJS.

What CommonJS is, and how Node.js implements it

In Node.js every file is a separate module with its own scope. A module publishes values by assigning to exports or module.exports, and another module obtains them by calling require() synchronously with a specifier: a built-in module name, a relative or absolute path, or a package name. The documentation states that the CommonJS loader is implemented in the module core module. It is JavaScript that ships inside the Node.js binary (built-in modules live in the lib/ folder of the Node.js source). It uses the file system to find and read the file, V8 to compile the source, and, for .node files, the operating system’s dynamic loader. The loader is synchronous, which is why require() returns the exported value directly and why a module that needs asynchronous initialisation has to export a function or a promise instead. The runtime layers are described in Introduction & Architecture.

Table 1. The CommonJS API surface and the stability level of each piece (Node.js 26)
API What it is Stability

require(id), require.resolve(), require.resolve.paths()

Load a module, find its file name without loading it, list the lookup directories.

2 Stable

module, exports, filename, dirname

The module scope variables supplied by the wrapper.

2 Stable

require.cache, require.main

The module cache and the entry-point module.

2 Stable

module.children, module.id, module.filename, module.path, module.paths, module.loaded, module.require(), module.isPreloading

Properties and methods of the module object.

2 Stable (documented on the stable CommonJS page)

Folders as modules (package.json "main", index.js)

Passing a directory to require().

3 Legacy. Use subpath exports or subpath imports instead.

require.extensions

The table of loaders per file extension.

0 Deprecated since v0.10.6

module.parent

The first module that required this one.

0 Deprecated (DEP0144, documentation-only; --pending-deprecation makes it warn)

process.mainModule

Older alias of require.main.

Deprecated (DEP0138, documentation-only)

require() of an ES module

Loads a synchronous ES module graph. See Loading ES modules with require().

2 Stable; no longer experimental in v25.4.0

Package maps (--experimental-package-map)

Resolve bare specifiers from a JSON file instead of the node_modules layout. Added in v26.4.0.

1 Experimental

import of .node add-ons

Loading a native add-on from an ES module.

1.2 Release candidate

The meaning of the stability indices is explained once for the whole section in Globals & Web Platform APIs.

When is a file CommonJS?

Node.js decides per file. A file is CommonJS when it has a .cjs extension, or when it has a .js extension (or none) and the nearest parent package.json has "type": "commonjs". If there is no "type" field, or no package.json at all, a .js file is also treated as CommonJS, unless it contains syntax that only parses as an ES module (import, export, top-level await, import.meta); in that case syntax detection runs it as an ES module instead. The documentation recommends that package authors always write the "type" field, even for pure CommonJS code, so that tools do not have to guess. Calling require() always uses the CommonJS loader and calling import() always uses the ES module loader, whatever the file looks like.

Syntax detection in a package without a "type" field, and its absence when the type is explicit
$ cat detect.js
import { basename } from 'node:path'; console.log('ESM by detection, require is', typeof require, basename(import.meta.filename));

$ node detect.js                  # no package.json above: detected as an ES module
ESM by detection, require is undefined detect.js

$ cat typed/package.json
{ "type": "commonjs" }
$ node typed/detect.js            # same file, explicit CommonJS: no detection
SyntaxError: Cannot use import statement outside a module

The same output came out of Node.js 26, 24 and 22. A file that detection turned into an ES module has no require, no module and no exports, which is the most common reason for the message require is not defined in ES module scope after a refactor. The complete decision procedure is on ECMAScript Modules.

The module wrapper and the module scope

Before a module’s code runs, Node.js wraps it in a function that receives five parameters: exports, require, module, filename and dirname. That is why top-level var, let and const declarations are private to the file instead of becoming properties of the global object, and why these five names look like globals but differ in every module.

Inspecting the scope the wrapper creates
// wrapper.js
const path = require('node:path');
console.log('arguments.length:', arguments.length);
console.log('parameters:', typeof exports, typeof require, typeof module, typeof __filename, typeof __dirname);
console.log('this === exports:', this === exports, '| exports === module.exports:', exports === module.exports);
console.log('__filename:', path.basename(__filename), '| __dirname:', path.basename(__dirname));
var title = 'Dune';
console.log('var title is module-private:', typeof globalThis.title === 'undefined');
Output on Node.js 26, 24 and 22 (identical)
arguments.length: 5
parameters: object function object string string
this === exports: true | exports === module.exports: true
__filename: wrapper.js | __dirname: ex1
var title is module-private: true

Two consequences of the function wrapper are worth knowing. A top-level return is legal in a CommonJS file (it ends the module early; it was run on Node.js 26 and 22), and top-level await is not: in a package marked "type": "commonjs" it raises SyntaxError: await is only valid in async functions and the top level bodies of modules. Top-level this is module.exports, not globalThis.

Table 2. The two path variables
Variable Meaning Details

__filename

Absolute path of the current module file.

Symbolic links are resolved, so for the main program it is not necessarily the name typed on the command line. For code inside node_modules/b/b.js it is that file, not the file of the module that required it.

__dirname

Directory of the current module.

Same as path.dirname(__filename).

The symbolic-link rule was measured: running node link/prog.js, where link is a symbolic link to the directory real, printed real/prog.js for filename. The module loader uses the real path both as the identity of the module and as the starting point for finding its dependencies; --preserve-symlinks changes that for the dependency lookup, see CLI Options, Environment & Config File. In ES modules filename and __dirname do not exist; use import.meta.filename and import.meta.dirname.

exports versus module.exports

Each module starts with module.exports set to an empty object, and exports is only a variable that was initialised with a reference to that same object. require() returns whatever module.exports holds when the module finishes. So adding properties through either name works, while assigning to exports merely rebinds a local variable.

Properties on the shared object, a lost assignment, and a function as the export
// shelf.js
exports.name = 'shelf';              // adds a property to the original object
exports = { name: 'lost' };          // rebinds the local variable only
module.exports.kind = 'wooden';      // still the original object
// counter.js: replacing module.exports with a function (a factory)
let created = 0;
module.exports = function createCounter() {
  created += 1;
  let n = 0;
  return { next: () => ++n, id: created };
};
module.exports.version = 2;
// main.js
const shelf = require('./shelf.js');
console.log('shelf:', shelf);
const createCounter = require('./counter.js');
const c1 = createCounter();
const c2 = createCounter();
console.log(c1.next(), c1.next(), c2.next(), 'ids', c1.id, c2.id, 'version', createCounter.version);
Output on Node.js 26, 24 and 22 (identical)
shelf: { name: 'shelf', kind: 'wooden' }
1 2 1 ids 1 2 version 2

The module-level created variable survives between calls because the module executes only once (see Caching); each createCounter() call gets its own n. When you replace module.exports with a function or class, attach extra properties to that new value (as version above), or write the idiom module.exports = exports = …​ if later lines still use exports.

Table 3. Which form to use
Goal Form

Several named values

exports.name = …​ or module.exports = { a, b }

One function, class or instance

module.exports = …​

Add a property to a function export

Assign it after the module.exports = …​ line

A value that is only known later

Export a function that returns a promise; do not assign inside a callback

Assignment to module.exports must be synchronous. If the assignment happens inside a callback, whoever required the module already holds the old object:

A late assignment is invisible to the first consumer
// late.js
setTimeout(() => { module.exports = { a: 'hello' }; }, 0);

// consumer
const late = require('./late.js');
console.log('late.a right away:', late.a);
setTimeout(() => console.log('late.a afterwards:', late.a, '| fresh require:', require('./late.js').a), 10);
Output on Node.js 26, 24 and 22 (identical)
late.a right away: undefined
late.a afterwards: undefined | fresh require: hello

The cache keeps the module object, not a copy of the export, so a later require() sees the new value while the consumer that already destructured or stored the old one never does. Neither exports nor module exist in an ES module; code that mixes them with import fails with a ReferenceError.

Resolution: how require() finds a file

require(X) called from a module in directory Y follows a fixed procedure. The documentation gives it in pseudocode (require(X) from module at path Y); the table is the same procedure in prose, in the order it is applied.

Table 4. The resolution steps for require(X)
Step Condition What happens

Built-in

X is a core module (fs, node:fs, node:sqlite)

The built-in module is returned and nothing else is searched. With the node: prefix it also bypasses require.cache.

Path

X is ., or starts with ./, / or ../

Try X as a file, then as a directory (LOAD_AS_FILE, then LOAD_AS_DIRECTORY); if neither exists, throw "not found".

Package imports

X starts with #

Look up the "imports" field of the closest package.json (conditions node, require, and module-sync).

Self-reference

X starts with the "name" of the closest package that has an "exports" field

Resolve through that package’s own "exports".

Package map

A package map is active (Experimental, Node.js 26.4.0 and later)

Resolve the package from the map instead of from node_modules.

node_modules

Anything else (a bare specifier)

Walk the node_modules directories from the current directory upwards, then the global folders (see The node_modules walk and Global folders and NODE_PATH).

Table 5. How a file or directory is loaded
Form Rule

File

Use the exact name if the file exists, and decide its format from its extension (and "type" for .js). Otherwise try X.js, then X.json, then X.node. A file with another extension such as .cjs must be required with its full name.

Directory

Read X/package.json and use its "main" field; if "main" is missing, falsy or does not resolve, fall back to X/index.js, X/index.json and X/index.node. See Folders as modules (Legacy).

Package with "exports"

When a package found in node_modules has an "exports" field, only the paths it lists can be required; the "main" field and arbitrary sub paths are ignored. See package.json, exports & imports.

The node_modules walk

For a bare specifier such as isbn, Node.js starts at the directory of the calling module, appends /node_modules, and tries to find the package there. If it is not found, it moves to the parent directory and repeats, until the root of the file system. Node.js never appends node_modules to a path that already ends in node_modules. A sub path after the package name resolves relative to the package (example-module/path/to/file), and scoped packages (@shelf/tools) are split into scope, name and sub path first. The loader looks up the real path of every module it loads and then searches the node_modules folders of that real path, which is what lets package managers install packages as symbolic links.

The scratch project for this page has this layout under one directory, ex3:

ex3/
  resolve.js
  lib/catalog.js            lib/catalog.json           lib/deep/where.js
  node_modules/isbn/        package.json (main: ./lib/isbn.js)   lib/isbn.js   lib/extra.js
  node_modules/@shelf/tools/   package.json, index.js
Resolving without loading: require.resolve() and its paths option
// resolve.js
const path = require('node:path');
const rel = (p) => path.relative(__dirname, p);
console.log('./lib/catalog     ->', rel(require.resolve('./lib/catalog')));   // .js is tried first
console.log('./lib/catalog.json->', rel(require.resolve('./lib/catalog.json')));
console.log('isbn              ->', rel(require.resolve('isbn')));            // package.json "main"
console.log('isbn/lib/extra    ->', rel(require.resolve('isbn/lib/extra')));  // sub path, no "exports" field
console.log('@shelf/tools      ->', rel(require.resolve('@shelf/tools')));    // index.js fallback
console.log('node:fs           ->', require.resolve('node:fs'));
console.log('with paths option ->', rel(require.resolve('isbn', { paths: [path.join(__dirname, 'lib', 'deep')] })));
try { require.resolve('./lib/missing'); } catch (err) {
  console.log(err.code, '|', err.message.split('\n')[0].replace(__dirname, '<dir>'));
  console.log('requireStack:', err.requireStack.map(rel));
}
Output on Node.js 26, 24 and 22 (identical)
./lib/catalog     -> lib/catalog.js
./lib/catalog.json-> lib/catalog.json
isbn              -> node_modules/isbn/lib/isbn.js
isbn/lib/extra    -> node_modules/isbn/lib/extra.js
@shelf/tools      -> node_modules/@shelf/tools/index.js
node:fs           -> node:fs
with paths option -> node_modules/isbn/lib/isbn.js
MODULE_NOT_FOUND | Cannot find module './lib/missing'
requireStack: [ 'resolve.js' ]

require.resolve() runs the real lookup but returns the file name instead of executing the file, so it is the right tool to answer "which copy of this package am I getting?", to check that an optional dependency exists, and to find a package’s directory. Its paths option replaces the default starting points (each entry starts a node_modules walk), except that the global folders are always included. When nothing matches, it throws an error whose code is MODULE_NOT_FOUND and whose requireStack property lists the modules that led to the failing call.

require.resolve.paths(request) returns the directories that would be searched, or null for a built-in module:

The lookup directories from a nested module
// lib/deep/where.js
const path = require('node:path');
const rel = (p) => path.relative(path.join(__dirname, '..', '..'), p);
console.log('module.paths:');
for (const p of module.paths.slice(0, 3)) console.log('  ', rel(p) || '.');
console.log('require.resolve.paths("isbn") first 3:');
for (const p of require.resolve.paths('isbn').slice(0, 3)) console.log('  ', rel(p));
console.log('node:fs ->', require.resolve.paths('node:fs'), '| fs ->', require.resolve.paths('fs'));
Output on Node.js 26, 24 and 22 (identical)
module.paths:
   lib/deep/node_modules
   lib/node_modules
   node_modules
require.resolve.paths("isbn") first 3:
   lib/deep/node_modules
   lib/node_modules
   node_modules
node:fs -> null | fs -> null

The paths are relative to the scratch directory here; real output is absolute and continues up to /node_modules, followed by the global folders. module.paths holds the same search list for the current module. The next listing shows what a package’s "exports" field does to a lookup:

A sealed package: "exports" wins over "main" and hides other files
{ "name": "sealed", "main": "./lib/old.js", "exports": { ".": "./lib/new.js", "./extra": "./lib/extra.js" } }
console.log(require('sealed'), '|', require('sealed/extra'));
try { require('sealed/lib/private'); } catch (err) { console.log(err.code); }
Output on Node.js 26 and 22
new entry | extra
ERR_PACKAGE_PATH_NOT_EXPORTED

Global folders and NODE_PATH

If a bare specifier is found nowhere in the node_modules walk, Node.js searches the directories listed in the NODE_PATH environment variable and then the historic global folders $HOME/.node_modules, $HOME/.node_libraries and $PREFIX/lib/node. The documentation says to prefer the local node_modules folder, which is faster and more reliable. NODE_PATH is a list of absolute paths separated by a colon on Unix and a semicolon on Windows, and it only affects CommonJS resolution. The order was measured with one package present both in NODE_PATH and in node_modules: the local copy won, and a path list separated by a comma found nothing:

NODE_PATH is a last resort, and its separator is the OS path separator
$ NODE_PATH=$PWD/shared:$PWD/shared2 node app/np.js     # app/node_modules/tag exists; shared/tag exists too
from node_modules
second path
$ NODE_PATH=$PWD/shared,$PWD/shared2 node app/np.js
from node_modules
MODULE_NOT_FOUND

This was run on Node.js 26. The ES module resolver ignores NODE_PATH completely; the "imports" field is the modern way to get short internal specifiers, see package.json, exports & imports.

Built-in modules and the node: prefix

Built-in modules are compiled into the binary. require('http') always returns the built-in module even if a file or package named http exists, and the node: prefix (supported by require() since v16.0.0 and v14.18.0) says the same thing explicitly and also bypasses require.cache. A few modules can only be loaded with the prefix, so that new built-ins cannot collide with packages that already use the name. module.builtinModules lists them, and since v23.5.0 that list includes the prefix-only ones.

Which built-ins need the prefix, on each line
const { builtinModules } = require('node:module');
const prefixed = builtinModules.filter((m) => m.startsWith('node:'));
console.log('node:-only built-ins:', prefixed);
for (const name of ['sqlite', 'test', 'node:sqlite']) {
  try { require(name); console.log(name, 'loads'); } catch (err) { console.log(name, '->', err.code); }
}
console.log("'fs' === 'node:fs':", require('fs') === require('node:fs'));
Table 6. Output
Node.js prefixed

26.10.0

node:ffi, node:sea, node:sqlite, node:test, node:test/reporters

24.21.0

node:sea, node:sqlite, node:test, node:test/reporters

22.23.3

empty list: builtinModules was not changed to include prefix-only modules on this line, although the modules themselves are available (the node: prefix works on all three lines)

On all three lines sqlite and test fail with MODULE_NOT_FOUND, node:sqlite loads and require('fs') === require('node:fs') is true. Always writing the prefix (node:fs) makes code immune to a user-land package shadowing the name, and it is the form used throughout this section.

Caching

A module is executed the first time it is required; the module object is stored in require.cache, keyed by the resolved file name, and every later require() that resolves to the same file returns the same module.exports. This makes modules behave like singletons, lets cycles terminate (see Circular dependencies), and means that to run code several times a module must export a function.

Same object, cache key, module.children, deleting a cache entry, and the node: bypass
// stamp.js
console.log('stamp.js is executing');
module.exports = { loadedAt: Math.random().toFixed(6) };

// cache.js
const a = require('./stamp');
const b = require('./stamp.js');
console.log('same object:', a === b);
const key = require.resolve('./stamp');
console.log('cache key is the resolved filename:', require('node:path').basename(key), key in require.cache);
console.log('module.children:', module.children.map((m) => require('node:path').basename(m.filename)));
delete require.cache[key];
const c = require('./stamp');
console.log('after delete, same object:', a === c);
// node: prefix and the cache
const fakeFs = {};
require.cache.fs = { exports: fakeFs };
console.log("require('fs') is fake:", require('fs') === fakeFs, "| require('node:fs') is real:", require('node:fs') !== fakeFs);
Output on Node.js 26 and 22 (identical)
stamp.js is executing
same object: true
cache key is the resolved filename: stamp.js true
module.children: [ 'stamp.js' ]
stamp.js is executing
after delete, same object: false
require('fs') is fake: true | require('node:fs') is real: true

Deleting a key from require.cache makes the next require() load a fresh copy; this was the basis of older hot-reload helpers, and node --watch (see Running Scripts, the CLI & the REPL) restarts the process instead, which is safer because code that already holds references to the old module keeps them. Deleting does not work for native add-ons (reloading them is an error), and adding entries that shadow a built-in name is possible, which is why the documentation says "use with care". Test doubles are better built with the test runner’s mocks.

Table 7. Caching caveats from the documentation
Situation Effect

The same package name resolves to different files

The cache key is the resolved file name, so two copies of one package installed under different node_modules folders are two modules with two separate states.

Case-insensitive file systems

require('./foo') and require('./FOO') produce two cache entries and two executions even though they are the same file.

The second row was reproduced on macOS (APFS, case-insensitive by default), on Node.js 26, 24 and 22:

One file, two modules
// foo.js prints 'Foo.js executing' when run
const a = require('./foo');
const b = require('./FOO');
console.log('same object:', a === b, '| cache entries for foo:', Object.keys(require.cache).filter((k) => /foo\.js$/i.test(k)).length);
Output
Foo.js executing
Foo.js executing
same object: false | cache entries for foo: 2

Circular dependencies

When module A requires B and B requires A, Node.js hands B the unfinished exports object of A, exactly as it is at that moment, instead of looping forever. Whatever A had assigned before calling require('./book.js') is visible; the rest is not.

Two modules that require each other
// author.js
console.log('author: start');
exports.ready = false;
const book = require('./book.js');
console.log('author: book.ready is', book.ready);
exports.ready = true;
console.log('author: end');

// book.js
console.log('book: start');
exports.ready = false;
const author = require('./author.js');
console.log('book: author.ready is', author.ready);
exports.ready = true;
console.log('book: end');

// main.js
const author = require('./author.js');
const book = require('./book.js');
console.log('main:', author.ready, book.ready);
Output on Node.js 26, 24 and 22 (identical)
author: start
book: start
book: author.ready is false
book: end
author: book.ready is true
author: end
main: true true

The cycle terminates because book sees author.ready === false. The shape that really breaks is replacing module.exports in a module that takes part in a cycle, because the other side keeps the object it received earlier:

Replacing module.exports inside a cycle
// trap-a.js
const b = require('./trap-b.js');
module.exports = { name: 'a', partner: b.name };   // a replaces its exports object after b already got the old one

// trap-b.js
const a = require('./trap-a.js');                  // b receives a's half-finished, still empty exports object
module.exports = { name: 'b', keysSeenOfA: Object.keys(a) };

// trap.js
console.log(require('./trap-a.js'), require('./trap-b.js'));
Output on Node.js 26, 24 and 22 (identical)
{ name: 'a', partner: 'b' } { name: 'b', keysSeenOfA: [] }

Fixes, in order of preference: remove the cycle by moving the shared code to a third module; require lazily inside the function that needs the other module; or export with exports.x = …​ only and never reassign module.exports in modules that take part in a cycle. On Node.js 26 and 22, reading a property that does not exist on a half-finished exports object during a cycle also printed a warning that names "circular dependency" (observed while experimenting; it is not described on the modules page), which is a useful clue when a value is mysteriously undefined. ES modules handle cycles differently, through live bindings, as covered in ECMAScript Modules.

The entry point: require.main

When a file is run directly with node file.js, require.main is that file’s module, so require.main === module is the classic "am I the program?" test. It is true when run directly and false when the file is loaded with require(). When the entry point is not a CommonJS module (for instance an .mjs file), require.main is undefined.

A CommonJS module that is both a library and a program
// tool.js
function main() { console.log('running as the entry point'); }
if (require.main === module) main();
else console.log('loaded by', require('node:path').basename(module.parent?.filename ?? 'unknown'));
module.exports = { main };

// user.js
require('./tool.js');
Output of node tool.js and node user.js on Node.js 26, 24 and 22
running as the entry point
loaded by user.js

The else branch above uses module.parent only to make the example visible. It is deprecated (DEP0144, documentation-only; with --pending-deprecation Node.js 26 printed module.parent is deprecated due to accuracy issues) because it is inaccurate in the presence of ES modules. To find the modules that required the current one, filter require.cache for entries whose children include module. process.mainModule (object in a CommonJS entry point) is the older alias of require.main and is deprecated for the same reasons (DEP0138). ES modules have no require.main; use import.meta.main, added in v24.2.0 and v22.18.0.

Loading JSON and native add-ons

Three file types go through require(): .js (and .cjs) files are compiled as JavaScript, .json files are parsed as JSON text, and .node files are loaded as compiled add-ons with process.dlopen(). Any other extension is treated as JavaScript text. Object.keys(require.extensions) printed [ '.js', '.json', '.node' ] on all three lines.

JSON is parsed once and the cached object is shared
// settings.json: { "shelves": 4, "owner": "Bookshelf" }
const settings = require('./settings.json');   // parsed with JSON.parse, cached like any module
settings.shelves += 1;                          // mutates the cached object
console.log('settings:', settings, '| same object next time:', require('./settings') === settings);
console.log('extensions handled:', Object.keys(require.extensions));
Output on Node.js 26, 24 and 22 (identical)
settings: { shelves: 5, owner: 'Bookshelf' } | same object next time: true
extensions handled: [ '.js', '.json', '.node' ]

Because the parsed object is cached, mutating it changes it for every other module in the process; copy it with structuredClone() if you need a private version. require('./settings') finds settings.json only because the extension search tries .json after .js; writing the extension is faster and clearer. require.extensions can register more handlers but is deprecated (Stability 0): the documentation recommends compiling ahead of time, or running a loader outside the process, instead; each registered extension also slows resolution.

A native add-on is a shared library that exposes Node-API (Stability 2 Stable, an ABI that stays stable across Node.js versions, so one build works on several lines). The next example is a complete add-on in C, compiled by hand against the headers that ship with Node.js; for real projects use node-gyp or CMake.js as described in the Learn guide.

A minimal Node-API add-on
// hello.c
#include <node_api.h>

static napi_value Hello(napi_env env, napi_callback_info info) {
  napi_value result;
  napi_create_string_utf8(env, "hello from a native add-on", NAPI_AUTO_LENGTH, &result);
  return result;
}

NAPI_MODULE_INIT(/* napi_env env, napi_value exports */) {
  napi_value fn;
  napi_create_function(env, "hello", NAPI_AUTO_LENGTH, Hello, NULL, &fn);
  napi_set_named_property(env, exports, "hello", fn);
  return exports;
}
Compile on macOS (the platform used for this run), then load from CommonJS
$ cc -shared -undefined dynamic_lookup -I"$(dirname "$(dirname "$(which node)")")/include/node" -o hello.node hello.c
$ node -e "const addon = require('./hello.node'); console.log(addon.hello(), '|', Object.keys(require.cache).some((k) => k.endsWith('hello.node')))"
hello from a native add-on | true

The same hello.node file loaded on Node.js 26, 24 and 22. Linux needs -fPIC and no -undefined dynamic_lookup, and Windows needs a different toolchain (these two were not run here). A .node file that is not a valid shared library fails with the error code ERR_DLOPEN_FAILED (measured with a text file renamed to fake.node). Loading an add-on a second time after deleting its cache entry is an error, as noted in the documentation, so the cache of add-ons cannot be reset.

Importing a .node file from an ES module needs --experimental-addon-modules, which is Stability 1.2 Release candidate. It became enabled by default in v24.19.0 and v26.5.0 according to each line’s CLI page; on Node.js 22.23.3 the unflagged import() failed with ERR_UNKNOWN_FILE_EXTENSION and succeeded with the flag, and on 24.21.0 and 26.10.0 it worked without the flag and printed an ExperimentalWarning. process.dlopen() is the underlying call; see the addons documentation and the Learn article on ABI stability for the reasons to prefer Node-API.

Folders as modules (Legacy)

Passing a directory to require() works in three ways, marked Stability 3 Legacy in the documentation ("Use subpath exports or subpath imports instead"):

  • the directory contains a package.json whose "main" points to the entry file;

  • there is no package.json, or "main" is missing or cannot be resolved, so Node.js looks for index.js and then index.node (the algorithm also lists index.json);

  • if all of that fails, the error is Cannot find module.

The three outcomes
// inventory/package.json: { "name": "inventory", "main": "./src/inventory.js" }
// byindex/index.js, and nomain/package.json with { "main": "./missing.js" } plus nomain/index.js
for (const dir of ['./inventory', './byindex', './nomain']) console.log(dir, '->', require(dir));
Output on Node.js 26, 24 and 22 (identical; the warning goes to stderr)
./inventory -> inventory via package.json main
./byindex -> byindex via index.js
./nomain -> nomain falls back to index.js
(node:21366) [DEP0128] DeprecationWarning: Invalid 'main' field in '.../nomain/package.json' of './missing.js'. Please either fix that or report it to the module author

The third line shows the fallback that DEP0128 deprecates: a package with an invalid "main" that also has an index.js still loads, with a runtime deprecation warning, and a future Node.js version is expected to throw. ES modules never do any of this: await import('./byindex') failed with ERR_UNSUPPORTED_DIR_IMPORT on all three lines. For code that must work with both loaders, declare entry points with "exports" and internal aliases with "imports".

Loading ES modules with require()

For years the rule was "`require()` cannot load an ES module". That changed: require() can now load an ES module graph that is fully synchronous, meaning it contains no top-level await. The ES module is eligible when the file is .mjs, or .js under "type": "module", or .js containing ES syntax in a package that does not say "type": "commonjs". The result is the module namespace object, so a default export is under .default; a namespace that has a default export also gets __esModule: true (the documentation marks that property as experimental and meant for tooling, and says hand-written code should not depend on it). An ES module can instead export the special name 'module.exports' to decide exactly what require() returns.

Namespace object, the 'module.exports' export name, and a top-level await graph
// isbn.mjs
export function check(s) { return /^\d{13}$/.test(s); }
export default class Isbn { constructor(v) { this.value = v; } }

// price.mjs
export function format(cents) { return `$${(cents / 100).toFixed(2)}`; }
export { format as 'module.exports' };

// later.mjs
await Promise.resolve();
export const ready = true;

// load.cjs
console.log('process.features.require_module:', process.features.require_module);
const isbn = require('./isbn.mjs');
console.log(isbn);
console.log('default is the class:', typeof isbn.default, '| check:', isbn.check('9781098145187'));
console.log("'module.exports' export:", require('./price.mjs')(1999));
try { require('./later.mjs'); } catch (err) { console.log(err.code); }
import('./later.mjs').then((ns) => console.log('import() works:', ns.ready));
Output on Node.js 26, 24 and 22 (identical)
process.features.require_module: true
[Module: null prototype] {
  __esModule: true,
  check: [Function: check],
  default: [class Isbn]
}
default is the class: function | check: true
'module.exports' export: $19.99
ERR_REQUIRE_ASYNC_MODULE
import() works: true

process.features.require_module detects the feature; --no-require-module turns it off (then the same code throws ERR_REQUIRE_ESM; the old name --no-experimental-require-module was also accepted on Node.js 26), and --trace-require-module=all prints where it is used (no-node-modules leaves out usage inside node_modules). --experimental-print-required-tla locates the top-level await when ERR_REQUIRE_ASYNC_MODULE is thrown; since v26.5.0 it prints the locations without evaluating the modules. The full interoperability story (named exports from CommonJS, createRequire, hooks, migration) is in ESM & CommonJS Interop.

Errors you will see

Table 8. Common module errors and what they mean
error.code Typical cause What to check

MODULE_NOT_FOUND

Nothing matched the specifier anywhere in the resolution procedure.

Spelling and relative path; the package is installed (npm ls); read err.requireStack; use require.resolve.paths().

ERR_PACKAGE_PATH_NOT_EXPORTED

The package has an "exports" field that does not list the sub path.

The package’s "exports"; ask the maintainer to export it.

ERR_REQUIRE_ASYNC_MODULE

require() of an ES module graph that has top-level await.

Use import(), or remove the top-level await.

ERR_REQUIRE_ESM

require() of an ES module with the feature disabled (--no-require-module).

Remove the flag or use import().

ERR_UNSUPPORTED_DIR_IMPORT

import() or import of a directory.

Import the file, or use "exports" / "imports".

ERR_DLOPEN_FAILED

A .node file is not a loadable shared library for this platform, architecture or Node.js.

Rebuild the add-on for the current platform; prefer Node-API.

What changed recently

Table 9. CommonJS-related changes, newest first (versions from the documentation, checked 2026-10-03)
Version Change

v26.5.0

--experimental-print-required-tla prints top-level await locations without evaluating modules; importing .node add-ons enabled by default (per the Node.js 26 CLI page; the Node.js 24 page records v24.19.0).

v26.4.0

Package maps (--experimental-package-map, Experimental) added to the require() algorithm.

v25.4.0

require(esm) is no longer experimental; the flag was renamed from --no-experimental-require-module to --no-require-module.

v24.2.0, v22.18.0

import.meta.main added, the ES module counterpart of require.main === module.

v23.5.0

module.builtinModules also lists prefix-only built-ins (not backported to 22.23.3 in the run above); require(esm) stops emitting an experimental warning by default (also v22.13.0, v20.19.0).

v23.0.0, v22.12.0, v20.19.0

require(esm) no longer needs --experimental-require-module; the 'module.exports' export name is supported (v23.0.0 and v22.12.0).

v22.7.0, v20.19.0

Syntax detection is on by default, so a .js file with ES syntax in a package without "type" runs as an ES module.

v16.0.0, v14.18.0

require() supports the node: prefix; DEP0128 (invalid "main" plus index.js) became a runtime deprecation in v16.0.0.

v14.6.0, v12.19.0

module.parent documented as deprecated (DEP0144).

Where the books are outdated

Efficient Node.js (Samer Buna, O’Reilly 2025) covers CommonJS next to ES modules in its chapter on scripts and modules: the five-parameter scope, require.resolve(), module.exports, require.main and caching. Most of that is still correct. Four points need updating. It says that ES modules have no simple way to check whether a file is the program and points to a third-party package plus import.meta.url; Node.js now has import.meta.main (v24.2.0 and v22.18.0), as the table above shows. It describes NODE_PATH as consulted first and as a comma-separated list; it is consulted after the node_modules walk and is separated by : on Unix and ; on Windows (both behaviours measured above), and it is ignored by ES modules (the ES module page says NODE_PATH is not part of resolving import specifiers; a import('tag') that worked through NODE_PATH in CommonJS failed with ERR_MODULE_NOT_FOUND). At least one listing uses exports in an ES module, where it does not exist. And the book does not mention loading an ES module with require(): since v22.12.0, v20.19.0 and v23.0.0 without a flag, and stable in v25.4.0, a synchronous ES module graph can be required, which removes the main reason to keep dual builds. Node.js Projects (Jonathan Wexler, O’Reilly 2025) mentions CommonJS only in passing, in a remark that developers now prefer ES modules, and its sample code sets "type": "module"; for CommonJS details use the official page.

The advice in older tutorials to hot-reload by clearing require.cache, to treat module.parent as the entry-point test, to register custom loaders through require.extensions, and to shorten paths with NODE_PATH should be replaced by node --watch, require.main === module (or import.meta.main), ahead-of-time compilation, and "imports" aliases respectively.

References