Load ES Modules with require in Node.js

This article explains how newer versions of Node.js allow developers to natively load ECMAScript Modules (ESM) using the traditional CommonJS require() function. We will cover the mechanics behind this feature, the specific conditions under which it operates, and how it simplifies dependency management in modern JavaScript environments.

The Historical Interoperability Barrier

Historically, Node.js maintained a strict barrier between CommonJS (CJS) and ES Modules (ESM). CJS modules relied on the synchronous require() function, while ESM used the asynchronous import statement.

Because ESM execution is asynchronous by design, loading an ES module from a CommonJS file was highly restricted. CommonJS developers were forced to use asynchronous dynamic import() expressions to load ESM dependencies. This created significant friction, as developers had to rewrite synchronous code paths to be asynchronous just to consume an ES module.

Synchronous ESM Loading via require()

To resolve this ecosystem divide, Node.js introduced support for synchronously loading ES modules using the standard require() function.

When you pass an ESM file path to require(), Node.js synchronously compiles and executes the target ES module. It then returns the module’s namespace object, with default exports mapped to the .default property and named exports mapped to their respective keys.

The Top-Level Await Constraint

For require() to successfully load an ES module, the target module must meet one critical condition: it must not contain top-level await statements.

Because require() is fundamentally synchronous, it cannot pause its execution thread to wait for a promise to resolve. If Node.js encounters a top-level await in the required ES module or any of its deep dependencies, it will immediately throw an ERR_REQUIRE_ESM error. If an ES module contains top-level await, it must still be loaded using dynamic import().

Version Support and Usage

This feature was first introduced in Node.js v20.12.0 and Node.js v22.0.0 under the command-line flag:

--experimental-require-module

Starting with Node.js v22.12.0, this behavior is enabled by default. You no longer need to pass any experimental flags to require compatible ES modules in your CommonJS code.

Example Code

Consider a simple ES module:

// math.mjs (ES Module)
export function add(a, b) {
  return a + b;
}

You can now require this module directly inside a CommonJS file:

// index.cjs (CommonJS Module)
const { add } = require('./math.mjs');

console.log(add(5, 10)); // Outputs: 15

Why This Matters

This update greatly simplifies package maintenance across the JavaScript ecosystem. Library authors no longer need to bundle their code into dual CJS/ESM distribution formats to support both environments. CommonJS applications can seamlessly adopt newer ESM-only libraries without undergoing a complete architectural rewrite.