ESM & CommonJS Interop
|
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 against the official documentation on 2026-10-03, and every example was run on all three
lines unless the text says otherwise.
Node.js has two module systems that were designed years apart, and a real application almost always touches both: a
CommonJS dependency that nobody will ever rewrite, an ESM-only package published last month, a .cjs configuration file next to
a .mjs entry point. This page is about the seams. It explains what each direction of the crossing gives you and costs you,
which loader picks up a given file, how to reach the CommonJS-only features from ESM, how to intercept loading with hooks, and how
to move a CommonJS project to ESM without a flag day. The Bookshelf application that grows across this section is used for the
examples. The module syntax itself is covered in CommonJS Modules and
ECMAScript Modules; the language-level view is in
JavaScript modules.
| API | Stability | Notes |
|---|---|---|
|
Stable (ES modules page: Stability 2) |
Named exports are a best-effort convenience, not a guarantee. |
|
Stable: no longer experimental since v25.4.0 |
The Node.js 24 documentation records the same change in v24.15.0; the Node.js 22 documentation still labels it 1.2 Release candidate. Unflagged since v22.12.0 and v23.0.0. |
|
No stability marker |
Added in v23.0.0, v22.10.0 and v20.19.0. |
Syntax detection (module type of ambiguous files) |
1.2 Release candidate |
Enabled by default since v22.7.0 (and v20.19.0). |
|
Stable (the |
|
|
1.2 Release candidate (since v25.4.0 and v24.13.1) |
Added in v23.5.0 and v22.15.0; the Node.js 22 documentation labels it 1.1 Active development. |
|
0 Deprecated |
Documentation-only deprecation in v25.9.0, runtime deprecation DEP0205 in v26.0.0. The hooks it takes are 1.1 Active development. |
|
1.2 Release candidate |
Replaces |
|
1.0 Early development |
Added in v24.2.0 and v22.18.0. |
|
1.2 Release candidate |
Enabled by default since v26.5.0. |
Package maps ( |
1 Experimental |
Added in v26.4.0; see package.json, exports & imports. |
Which loader runs my file?
Node.js never mixes the two systems inside one file: every file is parsed either as an ES module or as a CommonJS module, and
that decision is made before the first line runs. It depends on the file extension, on the nearest package.json, and, only as a
last resort, on the contents of the file. How the file was requested (entry point, import, import() or require()) then
decides what the other side sees. The chart summarises both decisions.
Four rules sit behind the chart:
-
.mjsis always ESM and.cjsis always CommonJS, whatever anypackage.jsonsays. TypeScript files follow the same rule (.mtsESM,.ctsCommonJS,.tsfollows the"type"field); see TypeScript in Node.js. -
A
.jsfile follows the"type"field of the nearestpackage.json. The field applies to files reached byimportandimport()as well as to the entry point, and the lookup walks up the directory tree until it finds apackage.json. -
Only when there is no
package.json, or it has no"type"field, does Node.js look at the source. It first tries to run the file as CommonJS and falls back to ESM if the file contains syntax that CommonJS cannot parse. That is syntax detection. -
A string given to
--evalor piped through standard input is CommonJS unless--input-type=moduleis given, or syntax detection finds ES module syntax.
The next example proves the detection rules. The files live in a directory without any package.json above it; they are the same
three files on all three lines.
# esm-syntax.js: import { sep } from 'node:path'; console.log('ESM detected', sep === '/', typeof require);
# plain.js: console.log('CommonJS by default', typeof require, typeof module);
# tla.js: const t = await Promise.resolve('tla'); console.log(t);
node esm-syntax.js
node plain.js
node tla.js
node --no-experimental-detect-module esm-syntax.js
The output was identical on Node.js 26, 24 and 22:
ESM detected true undefined
CommonJS by default function object
tla
SyntaxError: Cannot use import statement outside a module
ES module syntax is defined as syntax that would throw if evaluated as CommonJS: import and export statements (not
import() expressions), import.meta, top-level await, and a lexical redeclaration of one of the CommonJS wrapper variables
(require, module, exports, dirname, filename). Detection costs a second parse, so Node.js prints a warning when a
package.json exists without a "type" field and a file in it had to be detected. The warning was observed (it is not described
on the API pages) with the code MODULE_TYPELESS_PACKAGE_JSON on Node.js 26 and 22, and its text tells you to add
"type": "module" to the package. The documentation recommends being explicit in every package, including packages that are
entirely CommonJS, by writing the "type" field.
Importing CommonJS from ESM
An import statement or an import() expression can load a CommonJS file. A CommonJS module can hold anything in
module.exports: an object, a function, a class, a string. ES modules need to know their export names before the module runs,
so Node.js builds a namespace wrapper around the CommonJS module before evaluating it:
-
The
defaultexport is alwaysmodule.exports, unmodified. This is the one reliable way in. -
On Node.js 24 and 26 the namespace also carries a
'module.exports'export name holding the same value. It is an unambiguous marker that the module is CommonJS (added in v23.0.0). It was not present on Node.js 22.23.3 in the measurements below, so do not depend on it on 22. -
In addition, a static analysis of the source text looks for export patterns and exposes the names it finds as named exports. The analysis is the convenience that makes
import { readFile } from 'some-cjs-package'work. The Node.js 26 and 24 documentation name the parsermerve; the Node.js 22 documentation namescjs-module-lexer. The behaviour documented is the same: common patterns, re-exports and the output of build tools and transpilers are recognised.
// catalogue.cjs
exports.title = 'Bookshelf';
exports.count = 0;
exports.bump = () => ++exports.count;
// single.cjs
module.exports = function greet(name) { return `Hello, ${name}`; };
module.exports.version = 2;
// dynamic.cjs
const names = ['alpha', 'beta'];
for (const name of names) exports[name] = name.toUpperCase();
module.exports.fixed = 'always detected';
// main.js, in a package with "type": "module"
import catalogue, { title, bump, count } from './catalogue.cjs';
import * as ns from './catalogue.cjs';
import greet, { version } from './single.cjs';
console.log(title, count, catalogue === ns.default, catalogue === ns['module.exports']);
bump();
console.log('named export copy:', count, '| live object:', catalogue.count);
console.log(Object.keys(ns));
console.log(greet('reader'), version);
const dynamic = await import('./dynamic.cjs');
console.log(Object.keys(dynamic));
console.log(dynamic.alpha, dynamic.default.alpha);
On Node.js 26.10.0 and 24.21.0 the output was:
Bookshelf 0 true true
named export copy: 0 | live object: 1
[ 'bump', 'count', 'default', 'module.exports', 'title' ]
Hello, reader 2
[ 'default', 'fixed', 'module.exports' ]
undefined ALPHA
On Node.js 22.23.3 the first line ended with true false (there is no 'module.exports' key) and the two key lists did not
contain it; everything else was identical. Four lessons are visible in this output:
-
Detected names are copies, taken when the namespace is built. After
bump()the namedcountis still0while the livecatalogue.countis1. The documentation states that live-binding updates and exports added later are not detected. -
A name that is assigned in a loop, computed or defined through an indirect mechanism (
alphaandbetaabove) is not detected;fixed, written as a plainmodule.exports.fixed =, is. Importing{ alpha }is therefore a link-time error, shown next. -
When
module.exportsis a function (or class), you import it asdefault. Named exports detected from properties added to it, likeversion, are a bonus. -
import * as nsis a snapshot object:ns.defaultandns['module.exports']are the real exports object.
// bad.js
import { alpha } from './dynamic.cjs';
console.log(alpha);
SyntaxError: Named export 'alpha' not found. The requested module './dynamic.cjs' is a CommonJS module, which may not support all module.exports as named exports.
CommonJS modules can always be imported via the default export, for example using:
import pkg from './dynamic.cjs';
const { alpha } = pkg;
The message is the same on all three lines and is the whole workaround: import the default, then destructure.
The __esModule double-default trap
Transpilers that compile ES modules to CommonJS, such as the TypeScript compiler targeting CommonJS or Babel, mark their output with
Object.defineProperty(exports, '__esModule', { value: true }) and put the default export in exports.default. Node.js does not honour
that marker when importing: the default of the namespace is still the whole module.exports object, so the real default export
sits one level deeper.
// transpiled.cjs
Object.defineProperty(exports, '__esModule', { value: true });
exports.default = function render() { return 'rendered'; };
exports.helper = () => 'helped';
// flagged.js
import lib, { helper } from './transpiled.cjs';
console.log(typeof lib, Object.keys(lib), helper());
console.log(typeof lib.default);
The output on all three lines was:
object [ 'default', 'helper' ] helped
function
lib is the exports object, not the function; the function is lib.default. Bundlers and browsers-oriented tools often unwrap that
second level automatically, Node.js does not, which is the most common reason an import works in a bundled build and fails in
plain Node.js. The named export helper, by contrast, is detected because the output is a recognisable pattern.
Loading ESM from CommonJS with import()
Dynamic import() is available in both module systems, can load both kinds of module, and returns a promise. It is the portable
way to reach ESM from CommonJS code that must also run on older Node.js lines, because it never throws for a graph that uses
top-level await. The require(esm) section below covers the synchronous alternative.
require() of an ES module
Until v22.12.0 and v23.0.0 a require() of an ES module threw ERR_REQUIRE_ESM, and ESM-only packages forced every CommonJS
consumer to switch to asynchronous import(). Node.js can now load an ES module with require() as long as the module graph is
fully synchronous. The feature was added behind a flag in v22.0.0 and v20.17.0, unflagged in v22.12.0, v23.0.0 and v20.19.0, lost its
default experimental warning in v22.13.0, v23.5.0 and v20.19.0, and was declared no longer experimental in v25.4.0.
require() accepts an ES module when both of these hold:
-
the module graph contains no top-level
awaitanywhere, in the file or in anything it imports; and -
the file is
.mjs, or.jsin a package with"type": "module", or.jswith no"type": "commonjs"and with ES module syntax (syntax detection applies).
What require() returns is the module namespace object, similar to what import() resolves to, but obtained synchronously.
// distance.mjs
export function distance(a, b) { return Math.hypot(b.x - a.x, b.y - a.y); }
// point.mjs
export default class Point {
constructor(x, y) { this.x = x; this.y = y; }
}
// shelf.mjs: the module.exports export name, see below
export function distance(a, b) { return Math.hypot(b.x - a.x, b.y - a.y); }
export default class Shelf {
constructor(name) { this.name = name; }
static distance = distance;
}
export { Shelf as 'module.exports' };
// slow.mjs, wrapper.mjs: top-level await anywhere in the graph
const config = await Promise.resolve({ ready: true });
export default config;
// wrapper.mjs
import './slow.mjs';
export const wrapped = true;
// main.cjs, in a package with "type": "commonjs"
const util = require('node:util');
console.log('require_module:', process.features.require_module);
const ns = require('./distance.mjs');
console.log(ns, typeof ns.distance);
const point = require('./point.mjs');
console.log(point, point.__esModule, typeof point.default);
const Shelf = require('./shelf.mjs');
console.log(Shelf, typeof Shelf.distance);
const { distance } = require('./shelf.mjs');
console.log(distance === Shelf.distance);
for (const file of ['./slow.mjs', './wrapper.mjs']) {
try {
require(file);
} catch (err) {
console.log(file, err.code, '-', err.message.split('\n')[0]);
}
}
import('./slow.mjs').then((m) => console.log('import() works:', util.inspect(m.default)));
The output was identical on Node.js 26.10.0, 24.21.0 and 22.23.3:
require_module: true
[Module: null prototype] { distance: [Function: distance] } function
[Module: null prototype] { __esModule: true, default: [class Point] } true function
[class Shelf] { distance: [Function: distance] } function
true
./slow.mjs ERR_REQUIRE_ASYNC_MODULE - require() cannot be used on an ESM graph with top-level await. Use import() instead. To see where the top-level await comes from, use --experimental-print-required-tla.
./wrapper.mjs ERR_REQUIRE_ASYNC_MODULE - require() cannot be used on an ESM graph with top-level await. Use import() instead. To see where the top-level await comes from, use --experimental-print-required-tla.
import() works: { ready: true }
Each line of output teaches one rule:
-
A module with only named exports comes back as the namespace object: a
[Module: null prototype]with the names on it. -
A module with a default export comes back with a
defaultproperty and aesModule: trueproperty. The marker is added only when there is a default export and the namespace does not already defineesModule. The documentation describes it as experimental and intended for tools that convert ES modules to CommonJS; hand-written CommonJS should not depend on it. -
An ES module can control what
require()returns by exporting a value under the string name'module.exports', asshelf.mjsdoes.require('./shelf.mjs')is then the class itself, sonew Shelf(…)works for a CommonJS consumer. The cost is stated in the documentation: the other named exports are lost to CommonJS consumers, so the example hangsdistanceon the class as a static member. Importing the same module from ESM is unaffected:import Shelf fromstill gets the default export. -
A top-level
awaitanywhere in the graph, here two levels deep inwrapper.mjs, throwsERR_REQUIRE_ASYNC_MODULE.import()loads the same graph without trouble.
Detecting and controlling the feature
process.features.require_module is true when the build supports require(esm). Three command line options control it, all
documented in the CLI reference.
| Option | Effect | Observed on Node.js 26 / 24 / 22 |
|---|---|---|
|
Disables loading ES modules with |
Observed to work on 26 and 24. On 22.23.3 it fails with |
|
Legacy alias of |
Accepted on all three lines. |
|
Prints each use of |
Observed on Node.js 26 only: it prints a warning that calls the feature experimental, naming the requiring and the required file, even though the feature is stable. |
|
When the |
Printed the offending line ( |
The error object has more to say than its message. Since v26.5.0 it also carries requireStack and topLevelAwaitLocations
properties; on Node.js 26.10.0 and 24.21.0 topLevelAwaitLocations was populated only when --experimental-print-required-tla was enabled
and was undefined without the flag (Node.js 22 was not tested).
node --experimental-print-required-tla props.cjs
With the props.cjs script of the previous example set up to catch the error and print err.topLevelAwaitLocations, the output on
Node.js 26.10.0 was an array with one object: the url of slow.mjs, line 1, column 16, and the sourceLine
const config = await Promise.resolve({ ready: true });.
ERR_REQUIRE_ESM itself is deprecated since v23.0.0, v22.12.0 and v20.19.0, because require() supports synchronous ES modules; it
remains the error that --no-require-module produces. A third error, ERR_REQUIRE_CYCLE_MODULE, is reserved for an immediate cycle
that crosses the CommonJS and ESM boundary during evaluation: an ES module cannot be evaluated while it is already being evaluated.
The documented remedy is to move the require() call involved in the cycle out of the top level.
Source: Node.js API: Errors (ERR_REQUIRE_CYCLE_MODULE)
Conditional exports and module-sync
For a library author, require(esm) removes the main reason for shipping two copies of a package. A package that is ESM only is
now loadable from CommonJS on every supported Node.js line, so the dual-package hazard (two copies of the code in one process,
with distinct state) does not arise at all. When a package does ship both builds, the "module-sync" export condition matches
for import, import() and require() and expects an ES module without top-level await in its graph; it is ordered before
"default". Writing the package’s exports is covered in package.json, exports & imports.
{
"name": "shelf-lib",
"version": "1.0.0",
"type": "module",
"exports": {
"module-sync": "./index.js",
"default": "./index.cjs"
}
}
Installed in node_modules, with index.js exporting flavour = 'ESM' and index.cjs exporting 'CJS', both
require('shelf-lib') and import('shelf-lib') resolved to the ES build on Node.js 26, 24 and 22. With --no-require-module on
Node.js 26 both resolved to the CommonJS fallback, which means the condition is not matched when the feature is switched off,
even for import. That is why a "default" entry must always remain.
What ESM does not have, and what to use instead
A module in ESM is not wrapped in a function, so the CommonJS wrapper variables and the require machinery do not exist. The
ES modules reference lists each difference with its replacement; the table adds the measured behaviour.
| CommonJS | ESM replacement | Notes |
|---|---|---|
|
|
|
|
|
Not defined in ES modules. |
|
|
Both stable since v24.0.0 and v22.16.0; only present for |
|
|
1.0 Early development; added in v24.2.0 and v22.18.0. |
|
|
Returns a URL string, not a path; relative resolution can also use |
|
None |
The ES module loader has its own cache. An imported JSON module also creates an entry in the CommonJS cache. |
|
Customization hooks |
See Customization hooks. |
|
Symlinks, |
Not part of resolving import specifiers. |
Loading |
|
The attribute is mandatory; only a default export exists. Import attributes are no longer experimental since v23.1.0, v22.12.0 and v20.18.3. |
Native addons ( |
|
The ES modules page still lists addon loading as unsupported, while the CLI reference says |
Folders as modules, extension search |
Fully specified paths |
|
createRequire() for the CommonJS-only corners
module.createRequire(filename) returns a require function whose resolution base is the given file, which can be a path, a file
URL string or a URL. Created in an ES module with import.meta.url, it gives access to everything require does: loading JSON
without an import attribute, loading native addons, calling require.resolve(), and loading a CommonJS-only package.
import { createRequire, isBuiltin, builtinModules } from 'node:module';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
// JSON without an import attribute, a CommonJS-only package, require.resolve
const shelf = require('./shelf.json');
const legacy = require('./legacy.cjs');
console.log(shelf.shelf, legacy.legacy);
console.log(require.resolve('./legacy.cjs').endsWith('legacy.cjs'));
console.log(typeof require.cache, require.main);
// the ESM-native counterpart of require.resolve()
console.log(fileURLToPath(import.meta.resolve('./legacy.cjs')) === require.resolve('./legacy.cjs'));
// is this specifier a built-in module?
for (const name of ['node:fs', 'fs', 'fs/promises', 'node:test', 'test', 'node:sqlite', 'sqlite', 'wss']) {
console.log(name.padEnd(12), isBuiltin(name));
}
console.log(builtinModules.includes('node:test'), builtinModules.includes('test'));
The output on Node.js 26.10.0 and 24.21.0 was:
A1 true
true
object undefined
true
node:fs true
fs true
fs/promises true
node:test true
test false
node:sqlite true
sqlite false
wss false
true false
Node.js 22.23.3 printed the same lines except the last one, which was false false: builtinModules there does not list the
prefix-only modules.
-
require.mainof a createdrequireisundefined; there is no main module in an ES module. Useimport.meta.main. -
module.isBuiltin(moduleName)answers whether a specifier names a built-in module and accepts both the prefixed and the bare form ('fs'and'node:fs'). It reportsfalsefor'test'and'sqlite': those modules exist only under thenode:prefix, which is also why a bare name that is not built in is looked up innode_modules. Prefernode:for built-ins so the intent is visible. Added in v18.6.0 and v16.17.0. -
module.builtinModulesis the list of names; check it withisBuiltin()rather thanincludes(), as shown above.
Built-in modules have a CommonJS twin
A built-in module is the same object whether you require() it or import it. The ES named exports are snapshots of the CommonJS
properties, which matters when something patches a built-in at run time. module.syncBuiltinESMExports() updates the live
bindings of the built-in ES modules to match the current CommonJS exports; it never adds or removes names.
import fs, { readFileSync } from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
fs.readFileSync = () => Buffer.from('patched');
console.log(readFileSync === fs.readFileSync); // false: the named export is a snapshot
syncBuiltinESMExports();
console.log(readFileSync === fs.readFileSync); // true after syncing
The output was false then true on all three lines. Test frameworks and mocking libraries rely on this; application code rarely
should.
Customization hooks
Everything above is Node.js doing the work. Customization hooks let your code take part in the two steps that every module
goes through: resolve (turn a specifier into a URL and a format hint) and load (turn a URL into source code and a format).
Typical uses are loading a file type Node.js does not know (YAML, CSV, text), aliasing specifiers, instrumenting or transpiling
source, and mocking modules in tests. Hooks apply to import, import() and require() alike.
Node.js has had two hook systems:
module.registerHooks() |
module.register() |
|
|---|---|---|
Runs |
Synchronously, on the thread that loads the modules, in the same realm |
Asynchronously, on a dedicated worker thread, with message passing needed to share state |
Hooks you write |
|
|
Affects |
|
Mainly ES modules; the documentation lists caveats for CommonJS and says |
Removal |
The returned object has |
Not possible |
Stability |
1.2 Release candidate (since v25.4.0 and v24.13.1) |
0 Deprecated; DEP0205 runtime deprecation since v26.0.0 |
Added |
v23.5.0 and v22.15.0 |
v20.6.0 and v18.19.0 |
The documentation recommends the synchronous hooks in most cases, because the asynchronous variety brings the overhead of inter-thread communication and caveats that are uncertain to be solvable.
Writing synchronous hooks
A hook receives the next hook in the chain and either calls it or ends the chain by returning shortCircuit: true. Every hook must
return a plain object; a hook that returns without calling the next hook and without shortCircuit: true throws, which prevents
accidentally breaking the chain. The example below adds a made-up bookshelf: specifier scheme (resolve hook) and a way to load
.txt files as modules whose default export is the file contents (load hook).
// hooks.js
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const hooks = registerHooks({
// resolve: map a made-up scheme onto a real file
resolve(specifier, context, nextResolve) {
if (specifier === 'bookshelf:greeting') {
return nextResolve('./greeting.txt', context);
}
return nextResolve(specifier, context);
},
// load: turn any .txt file into a module that exports its text
load(url, context, nextLoad) {
if (url.endsWith('.txt')) {
const text = readFileSync(fileURLToPath(url), 'utf8');
return {
format: 'module',
source: `export default ${JSON.stringify(text.trim())};`,
shortCircuit: true,
};
}
return nextLoad(url, context);
},
});
export { hooks };
// app.js
import { hooks } from './hooks.js';
import { createRequire } from 'node:module';
// Static imports run before registerHooks(), so load customised modules dynamically.
const { default: greeting } = await import('bookshelf:greeting');
console.log(greeting);
// The same hooks also apply to require()
const require = createRequire(import.meta.url);
console.log(require('./greeting.txt').default);
hooks.deregister();
await import('./second.txt').catch((err) => console.log('after deregister:', err.code));
With greeting.txt containing one line of text and second.txt another, the output on Node.js 26, 24 and 22 was:
Hello from a text file
Hello from a text file
after deregister: ERR_UNKNOWN_FILE_EXTENSION
Three points deserve attention. First, the load hook returned format: 'module' for a file that Node.js cannot parse natively;
that is how a hook teaches the loader a new file type. After deregister() the default behaviour is back, and .txt is an unknown
extension again. Second, the hooks are active only for code loaded after registration. A static import is evaluated before
the importing module’s body, so the module that registers hooks must load the customised modules with import() or
require(), as the example does. Third, one require() call is customised by the same hooks as an import: a single mechanism
covers both systems.
Preloading hooks with --import or --require
To make hooks apply to the entry point and to everything it loads, register them in a file preloaded with --import or
--require. The hooks then also reach worker threads started with the same flags by inheritance of process.execArgv, which
synchronous hooks registered from the entry point do not. The example is a tracer that prints the module format chosen for every
file URL.
// trace.cjs
const { registerHooks } = require('node:module');
const { basename } = require('node:path');
registerHooks({
load(url, context, nextLoad) {
const result = nextLoad(url, context);
if (url.startsWith('file:')) {
console.log(`load ${basename(url).padEnd(12)} format=${result.format}`);
}
return result;
},
});
// target.js, in a package with "type": "module"
import { helper } from './helper.cjs';
import './helper.mjs';
console.log(helper());
Run with node --require ./trace.cjs target.js (the same works with --import and an ESM file), where helper.cjs is
exports.helper = () ⇒ 'from cjs'; and helper.mjs is export const x = 1;. The output was the same on all three lines:
load target.js format=module
load helper.cjs format=commonjs
load helper.mjs format=module
from cjs
The entry point itself goes through the hook, which a hook registered from target.js could never achieve.
Hook chains run last in, first out
registerHooks() can be called more than once. The hooks form a chain that runs last-in, first-out: the most recently registered
hook is called first and reaches the earlier ones, and finally Node.js' default behaviour, through nextResolve() and nextLoad().
import { registerHooks } from 'node:module';
const tag = (name) => ({
resolve(specifier, context, nextResolve) {
console.log(`resolve hook ${name}: ${specifier}`);
return nextResolve(specifier, context);
},
});
registerHooks(tag('first registered'));
registerHooks(tag('second registered'));
await import('./helper.mjs');
The output was resolve hook second registered: ./helper.mjs and then resolve hook first registered: ./helper.mjs, on Node.js 26
and 22. The formats a load hook may finally return are 'addon', 'builtin', 'commonjs', 'commonjs-typescript', 'json', 'module',
'module-typescript' and 'wasm'; the source of a 'builtin' cannot be replaced.
Hooks are code that runs inside your process on every module load. Treat a package that asks you to preload hooks, for example
node --import some-package/register, with the same trust as any other code that runs at start-up, and keep hook code small and
free of work that depends on loading modules that the hooks themselves are about to customise.
module.register() is deprecated
module.register(specifier[, parentURL][, options]) loads a module that exports asynchronous resolve and load hooks (and an
optional initialize) on a separate thread. It was the only hook mechanism from v20.6.0 and v18.19.0 until registerHooks()
arrived. The project found that supporting off-thread hooks "has proven to be complex" and has unresolvable issues, so
DEP0205 deprecates it, with removal planned for a future version. The deprecation is documentation-only in v25.9.0 (and v24.15.0, per the Node.js 24 documentation) and a
runtime deprecation since v26.0.0.
// register-old.mjs
import { register } from 'node:module';
register('./loader-old.mjs', import.meta.url);
console.log('registered with module.register()');
// loader-old.mjs
export async function resolve(specifier, context, nextResolve) {
return nextResolve(specifier, context);
}
On Node.js 26.10.0 the program prints registered with module.register() and then a warning. On Node.js 24.21.0 and 22.23.3 only
the first line appears.
(node:54664) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
Migrating is mostly mechanical, because the hook signatures are the same names with the callbacks now synchronous.
Before (module.register()) |
After (module.registerHooks()) |
|---|---|
Hooks live in a separate module that is registered by specifier |
Pass the hook functions inline; register from a |
|
Plain, synchronous |
|
Not needed: the hooks are closures in the same thread and realm; share state directly |
|
Not needed: use variables or the module graph directly |
No removal |
Keep the object returned by |
Only some |
Every |
Migrating a CommonJS project to ESM
With require(esm) and syntax detection the migration can be incremental and in either order. This section converts a small
CommonJS project, the Bookshelf catalogue, in one go and then looks at the other approaches.
// package.json: no "type" field
// { "name": "bookshelf-catalogue", "version": "1.0.0", "main": "src/index.js", "scripts": { "start": "node src/index.js" } }
// src/catalogue.js
const path = require('node:path');
const { readFileSync } = require('node:fs');
const books = JSON.parse(readFileSync(path.join(__dirname, 'books.json'), 'utf8'));
function titles() {
return books.map((book) => book.title);
}
module.exports = { titles };
// src/index.js
const { titles } = require('./catalogue');
function main() {
console.log(titles().join(', '));
}
if (require.main === module) main();
npm pkg set type=module
Source: npm Docs: npm pkg
The command sets the "type" field to "module" and npm pkg get type then prints "module". From now on every .js file in the
package is ESM. Files that must stay CommonJS, such as a configuration file read by a tool that cannot load ESM, are renamed to .cjs.
// package.json
// { "name": "bookshelf-catalogue", "version": "1.0.0", "type": "module",
// "exports": "./src/index.js", "engines": { "node": ">=22.12" },
// "scripts": { "start": "node src/index.js" } }
// src/catalogue.js
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const books = JSON.parse(readFileSync(join(import.meta.dirname, 'books.json'), 'utf8'));
export function titles() {
return books.map((book) => book.title);
}
// src/index.js
import { titles } from './catalogue.js';
export function main() {
console.log(titles().join(', '));
}
if (import.meta.main) main();
// legacy-consumer.cjs
const { titles } = require('./src/catalogue.js');
console.log(titles().length);
Before and after, npm start printed Efficient Node.js, Node.js Projects on Node.js 26, 24 and 22 (the titles are only sample
data), and legacy-consumer.cjs printed 2: an existing CommonJS caller keeps working through require(esm) as the module has no
top-level await. The engines value >=22.12 is where require(esm) and unflagged syntax detection are both available on the
22 line; import.meta.main needs 22.18 (and is 1.0 Early development), so it is the one line to revisit if you must support older 22.x releases.
The checklist for a real project:
| Step | What to do |
|---|---|
Declare the type |
|
Rewrite imports and exports |
|
Add extensions |
Relative specifiers must be fully specified: |
Replace the CommonJS variables |
|
Replace dynamic |
A conditional or computed |
Fix default-versus-named imports |
Check every import of a CommonJS dependency against Importing CommonJS from ESM: import the default if detection cannot see a name. |
Revisit tests and tooling |
Test runners, linters and TypeScript configuration may need to be told that the project is ESM. The Node.js test runner and type stripping work with both; see TypeScript in Node.js. |
Publish carefully |
Replace |
The inverse path is just as legitimate and sometimes cheaper: leave a CommonJS code base alone and consume ESM-only dependencies with
require() on Node.js 22.12 and later, or with import() when top-level await is involved. Another incremental approach is to
convert leaf modules first, name them .mjs, and require() them from the not-yet-migrated files.
Two diagnostic aids help the migration. Run the program with --no-experimental-detect-module (a flag listed in the CLI reference) to see
which files only work because of syntax detection, and with --trace-require-module=no-node-modules to list where your own
code relies on require(esm). The flags are described in CLI Options, Environment & Config File.
Common errors
The messages below were produced on Node.js 26.10.0 with small files. The error codes of the file-based cases were also checked on 24 and 22 and are the same.
| Error | Usual cause | Fix |
|---|---|---|
|
|
Use |
|
|
Rename to |
|
The static analysis did not detect |
|
|
|
Use |
|
|
Remove the flag, upgrade, or use |
|
|
|
|
Missing extension or a directory specifier in |
Spell out |
|
|
Register a |
Source: Node.js API: Errors
Pitfalls
-
Named imports are not live. Detected names are copies; if the CommonJS code reassigns
exports.xlater, read it through the default import (pkg.x). -
Two module systems, two caches.
require.cacheis invisible to ESM. In the measurements for this page an ES module loaded withrequire()and then withimport(), and a CommonJS file imported and then required, were each evaluated once on Node.js 26 and 22. The real duplication risk is a package that ships two builds (index.jsandindex.cjs): each consumer style gets its own copy, with its own state. Prefer a single ES build with"module-sync", as shown above, or see package.json, exports & imports. -
The transpiler marker is ignored.
__esModuledoes not makeimport x fromunwrapexports.default; see Importing CommonJS from ESM. -
A top-level
awaittaints every importer. Oneawaitin a deep dependency makes the whole path unrequirable. Libraries that want CommonJS consumers should not use it in modules that are reachable from the package’s entry point. -
Hooks run late for static imports. Register from a preload file, or load the customised modules with
import()after registering. -
Syntax detection is a fallback, not a strategy. It costs a re-parse and a warning in a
package.jsonwithout"type". Be explicit.
What changed recently
| Version | Change |
|---|---|
v12.22.0, v14.17.0, v15.3.0 |
ES modules implementation stabilised. The module flag had been dropped earlier, in v13.2.0 (and v12.17.0). |
v12.20.0, v14.13.0 |
Detection of CommonJS named exports added. |
v14.8.0 |
Top-level await unflagged. |
v20.6.0, v18.19.0 |
|
v21.1.0, v20.10.0 |
Syntax detection added behind a flag. |
v22.0.0, v20.17.0 |
|
v22.7.0, v20.19.0 |
Syntax detection enabled by default ( |
v22.12.0, v23.0.0, v20.19.0 |
|
v23.0.0 |
|
v22.13.0, v23.5.0, v20.19.0 |
No experimental warning for |
v22.15.0, v23.5.0 |
|
v24.2.0, v22.18.0 |
|
v24.5.0, v22.19.0 |
Wasm modules no longer need |
v25.4.0, v24.13.1 |
|
v25.9.0 (v24.15.0) |
|
v26.0.0 |
|
v26.4.0 |
Package maps added (experimental). |
v26.5.0 |
|
Where the books are outdated
Efficient Node.js (Samer Buna, O’Reilly 2025) introduces the two module systems and tells the reader that a .cjs file can be
imported into an ES module and that a .mjs file can be imported into CommonJS. Both are true today, but the second
used to be possible only through asynchronous import(); the book presents CommonJS as synchronous and ES modules as asynchronous, so
that you must never mix them carelessly. Since v22.12.0 require() loads a synchronous ES module graph directly,
and ERR_REQUIRE_ASYNC_MODULE is the only barrier, which changes the usual advice on how to consume ESM-only packages. The same
chapter says addons cannot be used with ES module imports and should be loaded with createRequire(); createRequire() remains
correct, but the CLI reference now lists --experimental-addon-modules as a Release candidate that is on by default since v26.5.0.
The book was written before module.registerHooks() was a Release candidate, and its coverage of custom loading, where it mentions
any, should be read together with the deprecation of module.register(). The suggestion to switch with npm pkg set type=module
still works as written.
Node.js Projects (Jonathan Wexler, O’Reilly 2025) enables ES modules with "type": "module" and says that stable ES module support
arrived in Node.js 13.2.0. The flag was indeed removed in 13.2.0, but the documentation records the implementation as stabilised later, in v15.3.0
(and v14.17.0, v12.22.0). The book does not discuss mixing in CommonJS packages, named-export detection, import(), or createRequire(), so
the sections of this page on Importing CommonJS from ESM and What ESM does not have, and what to use instead fill that gap, and its example package.json keeps "main" where
modern packages publish "exports"; see package.json, exports & imports.
References
-
Node.js API: Modules: CommonJS modules, https://nodejs.org/docs/latest/api/modules.html
-
Node.js API: Modules: ECMAScript modules, https://nodejs.org/docs/latest/api/esm.html
-
Node.js API: Modules: node:module API, https://nodejs.org/docs/latest/api/module.html
-
Node.js API: Modules: Packages, https://nodejs.org/docs/latest/api/packages.html
-
Node.js API: Command-line options, https://nodejs.org/docs/latest/api/cli.html
-
Node.js API: Errors, https://nodejs.org/docs/latest/api/errors.html
-
Node.js API: Deprecated APIs, https://nodejs.org/docs/latest/api/deprecations.html
-
Node.js API: Process (
process.features.require_module), https://nodejs.org/docs/latest/api/process.html -
npm Docs: npm pkg, https://docs.npmjs.com/cli/v12/commands/npm-pkg/
-
O’Reilly: Efficient Node.js, Samer Buna, https://www.oreilly.com/library/view/efficient-node-js/9781098145187
-
O’Reilly: Node.js Projects, Jonathan Wexler, https://www.oreilly.com/library/view/node-js-projects/9781098173135/