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.

Table 1. Stability of the APIs covered on this page (Node.js 26 documentation)
API Stability Notes

import of a CommonJS file, named-export detection, import() from CommonJS

Stable (ES modules page: Stability 2)

Named exports are a best-effort convenience, not a guarantee.

require() of an ES module (require(esm))

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.

process.features.require_module

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

module.createRequire(), module.isBuiltin(), module.builtinModules, module.syncBuiltinESMExports()

Stable (the node:module page is Stability 2)

module.builtinModules lists prefix-only modules since v23.5.0, so not on Node.js 22.

module.registerHooks() (synchronous, in-thread customization hooks)

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.

module.register() (asynchronous, off-thread hooks)

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.

import.meta.resolve()

1.2 Release candidate

Replaces require.resolve() for ESM.

import.meta.main

1.0 Early development

Added in v24.2.0 and v22.18.0.

--experimental-addon-modules (import of .node addons)

1.2 Release candidate

Enabled by default since v26.5.0.

Package maps (--experimental-package-map)

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.

flowchart TD start(["Node.js is asked to load a file"]) --> ext{"File extension?"} ext -- ".mjs or .mts" --> esm["ES module loader"] ext -- ".cjs or .cts" --> cjs["CommonJS loader"] ext -- ".json" --> json["JSON module: import needs with type json, require needs nothing"] ext -- ".node" --> addon["Native addon: require or process.dlopen"] ext -- ".js or .ts" --> pkg{"Nearest package.json has a type field?"} pkg -- "module" --> esm pkg -- "commonjs" --> cjs pkg -- "no package.json, or no type field" --> detect{"Does the source contain ES module syntax?"} detect -- "yes: import, export, import.meta or top-level await" --> esm detect -- "no" --> cjs esm --> how{"Loaded with require?"} how -- "no: entry point, import or import()" --> async["Evaluated by the ES loader, top-level await allowed"] how -- "yes" --> tla{"Does the module graph use top-level await?"} tla -- "no" --> sync["Evaluated synchronously, require returns the namespace object"] tla -- "yes" --> err["Throws ERR_REQUIRE_ASYNC_MODULE"] cjs --> how2{"Loaded with import?"} how2 -- "yes" --> wrap["Namespace wrapper: default, 'module.exports' and detected names"] how2 -- "no: entry point or require" --> run["Runs inside the CommonJS module wrapper"]
Figure 1. Which loader runs my file, and what does the requester get?

Four rules sit behind the chart:

  • .mjs is always ESM and .cjs is always CommonJS, whatever any package.json says. TypeScript files follow the same rule (.mts ESM, .cts CommonJS, .ts follows the "type" field); see TypeScript in Node.js.

  • A .js file follows the "type" field of the nearest package.json. The field applies to files reached by import and import() as well as to the entry point, and the lookup walks up the directory tree until it finds a package.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 --eval or piped through standard input is CommonJS unless --input-type=module is 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.

Syntax detection in a typeless directory
# 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 default export is always module.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 parser merve; the Node.js 22 documentation names cjs-module-lexer. The behaviour documented is the same: common patterns, re-exports and the output of build tools and transpilers are recognised.

The Bookshelf catalogue as a CommonJS module
// catalogue.cjs
exports.title = 'Bookshelf';
exports.count = 0;
exports.bump = () => ++exports.count;
A function replacing module.exports, and exports computed at run time
// 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';
Default, named and namespace imports of CommonJS files
// 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 named count is still 0 while the live catalogue.count is 1. 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 (alpha and beta above) is not detected; fixed, written as a plain module.exports.fixed =, is. Importing { alpha } is therefore a link-time error, shown next.

  • When module.exports is a function (or class), you import it as default. Named exports detected from properties added to it, like version, are a bonus.

  • import * as ns is a snapshot object: ns.default and ns['module.exports'] are the real exports object.

The error for a name that detection cannot see
// 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.

Importing the output of a transpiler
// 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 await anywhere, in the file or in anything it imports; and

  • the file is .mjs, or .js in a package with "type": "module", or .js with 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.

Four ES modules that CommonJS will load
// 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;
Loading them from a CommonJS file
// 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 default property and a esModule: true property. The marker is added only when there is a default export and the namespace does not already define esModule. 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', as shelf.mjs does. require('./shelf.mjs') is then the class itself, so new 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 hangs distance on the class as a static member. Importing the same module from ESM is unaffected: import Shelf from still gets the default export.

  • A top-level await anywhere in the graph, here two levels deep in wrapper.mjs, throws ERR_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.

Table 2. Options that control require(esm)
Option Effect Observed on Node.js 26 / 24 / 22

--no-require-module

Disables loading ES modules with require(). A require() of an ES module throws ERR_REQUIRE_ESM. Renamed in v25.4.0 from --no-experimental-require-module.

Observed to work on 26 and 24. On 22.23.3 it fails with node: bad option; use --no-experimental-require-module there. process.features.require_module was false with either flag.

--no-experimental-require-module

Legacy alias of --no-require-module (Stability 3, Legacy).

Accepted on all three lines.

--trace-require-module=mode

Prints each use of require(esm). mode is all, or no-node-modules to hide uses from inside node_modules. Added in v23.5.0, v22.13.0 and v20.19.0.

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.

--experimental-print-required-tla

When the ERR_REQUIRE_ASYNC_MODULE error is uncaught, locates and prints the top-level awaits in the graph. Since v26.5.0 it prints them without evaluating the modules.

Printed the offending line (slow.mjs, the await expression) on Node.js 26.

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

Where is the top-level await?
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.

A package whose single ES build serves both import and require
{
  "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.

Table 3. CommonJS features and their ESM counterparts
CommonJS ESM replacement Notes

require('x')

import x from 'x', import('x'), or createRequire(import.meta.url)

require is not defined in ES module scope (the error message even says to use import).

exports, module.exports

export statements

Not defined in ES modules.

filename, dirname

import.meta.filename, import.meta.dirname

Both stable since v24.0.0 and v22.16.0; only present for file: modules. import.meta.url is the URL form.

require.main === module

import.meta.main

1.0 Early development; added in v24.2.0 and v22.18.0.

require.resolve()

import.meta.resolve(), or createRequire(…​).resolve()

Returns a URL string, not a path; relative resolution can also use new URL('./local', import.meta.url).

require.cache

None

The ES module loader has its own cache. An imported JSON module also creates an entry in the CommonJS cache.

require.extensions

Customization hooks

See Customization hooks.

NODE_PATH

Symlinks, "imports", or "exports"

Not part of resolving import specifiers.

Loading .json without ceremony

import data from './data.json' with { type: 'json' }

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

createRequire() or process.dlopen()

The ES modules page still lists addon loading as unsupported, while the CLI reference says --experimental-addon-modules is enabled by default since v26.5.0 (1.2 Release candidate). This page did not run an addon.

Folders as modules, extension search

Fully specified paths

import './dir' throws ERR_UNSUPPORTED_DIR_IMPORT and import './lib' throws ERR_MODULE_NOT_FOUND.

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.

createRequire(), import.meta.resolve() and module.isBuiltin() together
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.main of a created require is undefined; there is no main module in an ES module. Use import.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 reports false for 'test' and 'sqlite': those modules exist only under the node: prefix, which is also why a bare name that is not built in is looked up in node_modules. Prefer node: for built-ins so the intent is visible. Added in v18.6.0 and v16.17.0.

  • module.builtinModules is the list of names; check it with isBuiltin() rather than includes(), 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.

Patching a built-in and syncing the ESM view
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:

Table 4. The two hook APIs
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

resolve(specifier, context, nextResolve) and load(url, context, nextLoad), plain functions passed inline

resolve and load (plus initialize) exported by a separate module that you register by specifier

Affects

import, import() and require() of every module type

Mainly ES modules; the documentation lists caveats for CommonJS and says require() calls in the graph are not all covered

Removal

The returned object has deregister() (and [Symbol.dispose])

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

Resolve and load hooks, registered in one module
// 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 };
Using the hooks, and removing them
// 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.

A tracing load hook, preloaded with --require
// 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;
  },
});
A program mixing both module types
// 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().

Two registrations, one chain
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.

A module.register() call, to see the runtime deprecation
// 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.

Table 5. Migrating from module.register() to module.registerHooks()
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 --import or --require preload file for global effect

async function resolve() and async function load()

Plain, synchronous resolve() and load(); use readFileSync instead of awaiting I/O

initialize(data) receives a clone of options.data

Not needed: the hooks are closures in the same thread and realm; share state directly

MessageChannel and Atomics to talk to the main thread

Not needed: use variables or the module graph directly

No removal

Keep the object returned by registerHooks() and call deregister()

Only some require() calls were affected

Every import, import() and require() is affected

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.

The CommonJS starting point
// 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();
Step 1: declare the module type
npm pkg set type=module

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.

Steps 2 to 4: imports, exports and the CommonJS-only variables
// 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();
A CommonJS consumer of the migrated package
// 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:

Table 6. A migration checklist
Step What to do

Declare the type

"type": "module" in package.json (or rename the files to .mjs). Keep CommonJS-only files as .cjs.

Rewrite imports and exports

require() becomes import; module.exports becomes export or export default. A circular dependency that worked by accident in CommonJS may now fail at evaluation, so rerun the tests.

Add extensions

Relative specifiers must be fully specified: ./catalogue.js, never ./catalogue, never a directory. A directory import throws ERR_UNSUPPORTED_DIR_IMPORT, a missing extension ERR_MODULE_NOT_FOUND.

Replace the CommonJS variables

dirname and filename become import.meta.dirname and import.meta.filename; require.main === module becomes import.meta.main; require.resolve() becomes import.meta.resolve(); JSON gets with { type: 'json' } or createRequire().

Replace dynamic require()

A conditional or computed require() becomes await import() (top-level await is available in ES modules), or createRequire() where a synchronous result is needed.

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 "main" by "exports" and keep the engines field honest. Consumers on CommonJS can require() an ESM-only package without top-level await. See Workspaces & Publishing.

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.

Table 7. Interop errors and their usual cause
Error Usual cause Fix

ReferenceError: require is not defined in ES module scope, you can use import instead

require() in an ES module (a .mjs file or a .js file in a "type": "module" package).

Use import, or createRequire(import.meta.url).

SyntaxError: Cannot use import statement outside a module

import in a file that is CommonJS: .cjs, or "type": "commonjs".

Rename to .mjs, set "type": "module", or use import().

SyntaxError: Named export 'x' not found. The requested module is a CommonJS module…​

The static analysis did not detect x.

import pkg from '…​'; const { x } = pkg;.

ERR_REQUIRE_ASYNC_MODULE

require() of a graph that contains top-level await.

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

ERR_REQUIRE_ESM

require() of an ES module with --no-require-module, or an engine without the feature. The error is deprecated.

Remove the flag, upgrade, or use import().

TypeError [ERR_IMPORT_ATTRIBUTE_MISSING]: …​ needs an import attribute of "type: json"

import of a .json file without an attribute.

import data from './data.json' with { type: 'json' }.

Error [ERR_MODULE_NOT_FOUND] or ERR_UNSUPPORTED_DIR_IMPORT

Missing extension or a directory specifier in import.

Spell out ./lib.js or ./dir/index.js.

TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".txt"

import of a file type that only a hook could teach to Node.js.

Register a load hook (see Customization hooks) or load the file with fs.

Pitfalls

  • Named imports are not live. Detected names are copies; if the CommonJS code reassigns exports.x later, read it through the default import (pkg.x).

  • Two module systems, two caches. require.cache is invisible to ESM. In the measurements for this page an ES module loaded with require() and then with import(), 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.js and index.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. __esModule does not make import x from unwrap exports.default; see Importing CommonJS from ESM.

  • A top-level await taints every importer. One await in 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.json without "type". Be explicit.

What changed recently

Table 8. Interop timeline (versions from the Node.js 26, 24 and 22 documentation)
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

module.register() added; hooks move off the main thread in v20.0.0 and v18.19.0.

v21.1.0, v20.10.0

Syntax detection added behind a flag.

v22.0.0, v20.17.0

require(esm) added behind --experimental-require-module.

v22.7.0, v20.19.0

Syntax detection enabled by default (--no-experimental-detect-module disables it).

v22.12.0, v23.0.0, v20.19.0

require(esm) unflagged; 'module.exports' export name supported in require(esm); ERR_REQUIRE_ESM deprecated. Import attributes and JSON modules no longer experimental (v23.1.0, v22.12.0, v20.18.3 and v18.20.5). Import assertions dropped in v22.0.0.

v23.0.0

'module.exports' marker added to CommonJS namespaces on import (not backported: absent on Node.js 22.23.3).

v22.13.0, v23.5.0, v20.19.0

No experimental warning for require(esm) by default; --trace-require-module added.

v22.15.0, v23.5.0

module.registerHooks() added (synchronous, in-thread hooks). module.builtinModules includes prefix-only modules from v23.5.0.

v24.2.0, v22.18.0

import.meta.main added.

v24.5.0, v22.19.0

Wasm modules no longer need --experimental-wasm-modules.

v25.4.0, v24.13.1

registerHooks() becomes a Release candidate. require(esm) no longer experimental in v25.4.0 (v24.15.0 in the Node.js 24 documentation); flag renamed to --no-require-module.

v25.9.0 (v24.15.0)

module.register() deprecated in the documentation.

v26.0.0

module.register() runtime deprecation (DEP0205).

v26.4.0

Package maps added (experimental).

v26.5.0

--experimental-addon-modules enabled by default; ERR_REQUIRE_ASYNC_MODULE gains requireStack and topLevelAwaitLocations; --experimental-print-required-tla prints top-level awaits without evaluating the modules.

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