JavaScript ESM & CommonJS Interop: Importing Between Module Systems

JavaScript has two major module systems in everyday use: ECMAScript modules, usually called ESM, and the older CommonJS format. This article explains how they work together, what you can import from each one, and why module interop can fail in real projects.

Quick answer: In Node.js, ESM can import CommonJS modules, most reliably through a default import. Since Node.js 20.19 and 22.12, CommonJS can also load ESM synchronously with require(), as long as the ES module graph has no top-level await. In that case, and on older Node.js versions, use dynamic import().

Difficulty: Intermediate

You'll understand this better if you know: basic import and require() syntax, how modules export values, and the difference between synchronous and asynchronous code.

1. What Is ESM & CommonJS Interop?

ESM and CommonJS interop is the set of rules that let code written in one module system use code written in the other. In practice, this matters most in Node.js projects that still depend on older packages while gradually moving to modern import and export syntax.

  • ESM uses import and export.
  • CommonJS uses require() and module.exports.
  • Interop means one system can load the other, but not always in every direction or with identical behavior.
  • The main challenge is that the two systems do not represent exports the same way.

For beginners, the key idea is that a module system is not just syntax. It also affects when modules load, how values are exposed, and what shape the imported object has.

2. Why ESM & CommonJS Interop Matters

Most JavaScript projects do not live in a perfect world where every dependency uses the same module format. You may work in an older Node.js codebase, install a package that still publishes CommonJS, or switch one file at a time to ESM.

This topic matters because module mismatches can break applications at startup, during bundling, or only after deployment. Knowing the interop rules helps you:

  • choose the correct import style for a package
  • avoid runtime errors like unsupported require() of ES modules
  • understand why a CommonJS module appears under a default import in ESM
  • migrate older code without rewriting everything at once

Interop also matters for package authors. If you publish libraries for Node.js, you need to know how consumers will import your package from both module systems.

3. Basic Syntax

There are two main module styles to compare.

ESM syntax

Terminal
export const pi = 3.14159;

export default function areaOfCircle(radius) {
  return pi * radius * radius;
}

ESM exposes values with named exports and optionally one default export.

CommonJS syntax

JavaScript
const pi = 3.14159;

function areaOfCircle(radius) {
  return pi * radius * radius;
}

module.exports = areaOfCircle;

CommonJS exports a value by assigning to module.exports. That value may be a function, an object, a class, or anything else.

The core interop idea

When ESM imports CommonJS, it usually treats the CommonJS export as a single default-like value. When CommonJS loads ESM, it receives the module namespace object, either from require() (Node.js 20.19+ and 22.12+, for ES modules without top-level await) or from dynamic import(), which works in every version and returns a promise.

4. Step-by-Step Examples

Example 1: ESM importing a CommonJS default export

Suppose a CommonJS package exports one function using module.exports. ESM can import that value directly as the default import.

JavaScript
// math.cjs
function multiply(a, b) {
  return a * b;
}

module.exports = multiply;

// app.mjs
import multiply from "./math.cjs";

console.log(multiply(2, 4)); // 8

This works because Node.js maps the CommonJS export object to the ESM default import in the common case.

Example 2: ESM importing a CommonJS object with properties

If a CommonJS module exports an object, ESM still receives that object as the default export.

JavaScript
// config.cjs
module.exports = {
  port: 3000,
  start() {
    return "server started";
  }
};

// app.mjs
import config from "./config.cjs";

console.log(config.port); // 3000
console.log(config.start()); // server started

In ESM, the imported value is the whole CommonJS export object, not a set of named exports created automatically from its properties.

Example 3: CommonJS loading ESM with dynamic import

Dynamic import() lets a CommonJS file load any ES module, including one that uses top-level await. It returns a promise. On Node.js 20.19+ and 22.12+, require() is a synchronous alternative for ES modules without top-level await (see Example 5).

JavaScript
// logger.mjs
export function log(message) {
  return `LOG: ${message}`;
}

// app.cjs
(async () => {
  const logger = await import("./logger.mjs");
  console.log(logger.log("hello"));
})();

Here, import() gives CommonJS access to the ESM module namespace object after the module loads.

Example 4: Accessing named exports from a CommonJS namespace object

Sometimes a CommonJS module sets multiple properties on module.exports. From ESM, you still usually read those properties from the default import object.

JavaScript
// shapes.cjs
module.exports = {
  square(x) {
    return x * x;
  },
  cube(x) {
    return x * x * x;
  }
};

// app.mjs
import shapes from "./shapes.cjs";

console.log(shapes.square(5)); // 25
console.log(shapes.cube(3)); // 27

This pattern is common when moving a utility library from CommonJS to ESM gradually.

Example 5: CommonJS loading ESM with require()

Since Node.js 20.19 and 22.12, require() can load an ES module synchronously without any flag (22.12 still printed an experimental warning; 22.13 and 20.19 do not). It returns the module namespace object, so a default export is on the .default property.

JavaScript
// point.mjs
export default class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
}

export function distance(a, b) {
  return Math.hypot(b.x - a.x, b.y - a.y);
}

// app.cjs
const { default: Point, distance } = require("./point.mjs");

console.log(distance(new Point(0, 0), new Point(3, 4))); // 5

If CommonJS callers expect require() to return a value directly, as they did with module.exports = createServer, the ES module can export that value under the string name "module.exports" (Node.js 22.12+). Its other named exports are then not visible to require() callers.

JavaScript
// create-server.mjs
function createServer() {
  return "server";
}

export { createServer as "module.exports" };

// app.cjs
const createServer = require("./create-server.mjs");
console.log(createServer()); // server

Two limits remain. require() throws ERR_REQUIRE_ASYNC_MODULE if the ES module or anything it imports uses top-level await (see Mistake 1), and Node.js versions older than 20.19 and 22.12 reject every require() of an ES module with ERR_REQUIRE_ESM.

5. Practical Use Cases

  • Loading an older npm package from a modern ESM application.
  • Migrating a Node.js app file by file from require() to import.
  • Publishing a package that must still support consumers on older CommonJS codebases.
  • Using modern ESM in new code while keeping existing CommonJS build scripts alive during transition.
  • Writing tools, CLI apps, or server code that depend on a mixture of new and legacy dependencies.

6. Common Mistakes

Mistake 1: Using require() on an ES module that uses top-level await

Modern Node.js lets CommonJS require() most ES modules, so it is easy to forget the exception. A module that uses top-level await, directly or in anything it imports, cannot finish loading synchronously, so require() refuses it. On Node.js versions older than 20.19 and 22.12, require() fails for every ES module with ERR_REQUIRE_ESM.

Problem: Node.js throws Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM graph with top-level await. Use import() instead.

JavaScript
// config.mjs
const settings = await Promise.resolve({ port: 3000 });

export default settings;

// app.cjs
const config = require("./config.mjs");

Fix: Use dynamic import() for that module, or remove the top-level await from modules that CommonJS code needs to require().

JavaScript
// app.cjs
(async () => {
  const { default: config } = await import("./config.mjs");
  console.log(config.port);
})();

The corrected version works because import() can wait for asynchronous module evaluation, which require() cannot.

Mistake 2: Expecting named imports from a CommonJS export object

Many developers assume every property of module.exports becomes a named export in ESM. Node.js can only provide the names it finds by statically analyzing the CommonJS source. It recognizes patterns such as exports.name = … and module.exports = { a, b } with plain identifier values, but not literal values such as port: 3000 or properties added after the assignment.

Problem: port is assigned a literal value, so Node.js cannot detect it as a named export. The import fails before any code runs with SyntaxError: Named export 'port' not found. The requested module './config.cjs' is a CommonJS module, which may not support all module.exports as named exports.

JavaScript
// config.cjs
module.exports = {
  port: 3000,
  start() {
    return "server started";
  }
};

// app.mjs
import { port } from "./config.cjs";

Fix: Import the CommonJS default value and read its properties.

JavaScript
// app.mjs
import config from "./config.cjs";

console.log(config.port);

This works because the default import is always the whole module.exports object, whatever the static analysis detects.

Mistake 3: Assuming default means the same thing in both systems

ESM default export syntax and CommonJS module.exports can look similar in usage, but they are not identical. Confusing them causes import shape problems.

Problem: A CommonJS file does not understand ESM import syntax without being treated as an ESM file, and a default export from ESM is not the same as assigning to module.exports.

JavaScript
// wrong in a CommonJS file
import multiply from "./math.cjs";

Fix: Use the syntax that matches the file type, or convert the file to ESM.

JavaScript
// app.cjs
const multiply = require("./math.cjs");
console.log(multiply(2, 4));

The corrected version works because CommonJS uses require() and module.exports consistently.

7. Best Practices

Prefer one module system per package when possible

Mixed-module packages are harder to reason about and test. When you can, choose ESM for new code or keep a clearly separated CommonJS boundary for legacy code.

JavaScript
// better: keep the package internally consistent
export function sum(values) {
  return values.reduce((total, value) => total + value, 0);
}

Use import() at the boundary, not everywhere

If only one old dependency is CommonJS or one new dependency is ESM, isolate the compatibility code in a small adapter file. That keeps the rest of your code cleaner.

JavaScript
// adapter.cjs
module.exports = async function loadLogger() {
  return import("./logger.mjs");
};

Be explicit about file types and package intent

In Node.js, file extension and package configuration affect module interpretation. Use .mjs for ESM and .cjs for CommonJS when you need clarity during migration.

JavaScript
// clear file intent during migration
// utils.mjs - ESM
export const version = "1.0.0";

// legacy.cjs - CommonJS
module.exports = { version: "1.0.0" };

8. Limitations and Edge Cases

  • require() can load ES modules only on Node.js 20.19+ and 22.12+, and only when the module graph has no top-level await. Otherwise, use dynamic import().
  • ESM imports of CommonJS usually expose the CommonJS export as a default value, but behavior can vary with wrappers and tooling.
  • Live bindings work natively in ESM, while CommonJS exports are snapshots of assigned values unless you manage objects manually.
  • Package metadata such as type, exports, and file extensions strongly affect how Node.js interprets modules.
  • Tooling such as bundlers or test runners may emulate interop differently from Node.js, so code that works in one environment may fail in another.
  • Some packages publish separate entry points for ESM and CommonJS, which can change the shape of imports depending on how you access them.

9. Practical Mini Project

In this mini project, you will build a small CommonJS utility and consume it from an ESM application. This mirrors a common migration scenario.

Below, the utility is still CommonJS, while the app file uses modern ESM syntax.

JavaScript
// format.cjs
module.exports = {
  formatPrice(amount) {
    return `$${amount.toFixed(2)}`;
  },
  formatLabel(name) {
    return `Item: ${name}`;
  }
};

// app.mjs
import formatters from "./format.cjs";

const priceText = formatters.formatPrice(19.5);
const labelText = formatters.formatLabel("Notebook");

console.log(priceText);
console.log(labelText);

Expected output:

JavaScript
$19.50
Item: Notebook

This project shows the most practical interop pattern: ESM imports a CommonJS default object and uses its methods normally.

10. Key Points

  • ESM uses import and export; CommonJS uses require() and module.exports.
  • ESM can usually import CommonJS through a default import.
  • CommonJS can require() ES modules on Node.js 20.19+ and 22.12+ unless they use top-level await. Dynamic import() works in all cases.
  • Do not assume CommonJS properties are always available as named ESM exports.
  • Mixed-module code is easiest to maintain when compatibility logic is isolated.

11. Practice Exercise

  • Create a CommonJS module named strings.cjs that exports two functions: one to trim a string and one to uppercase it.
  • Write an ESM file named app.mjs that imports the CommonJS module and logs the result of both functions for the string " hello ".
  • Make sure your ESM file treats the CommonJS export as one default object.

Expected output:

JavaScript
hello
HELLO

Hint: export an object from CommonJS, then read its methods from the ESM default import.

Show solution
JavaScript
// strings.cjs
module.exports = {
  trimText(value) {
    return value.trim();
  },
  upperText(value) {
    return value.toUpperCase();
  }
};

// app.mjs
import strings from "./strings.cjs";

const input = "  hello ";

console.log(strings.trimText(input));
console.log(strings.upperText(strings.trimText(input)));

12. Final Summary

ESM and CommonJS interop is about understanding how two different module systems exchange values. The most important rule is that ESM commonly consumes CommonJS as a default-like import, while CommonJS loads ESM with require() on Node.js 20.19+ and 22.12+ (when the module has no top-level await) and with dynamic import() everywhere else.

Once you understand the direction of compatibility, the rest becomes easier to predict. Many import problems come from assuming the two systems behave the same, especially around default exports, named exports, and synchronous loading.

For real-world work, keep module boundaries small, be explicit about file types, and test interop behavior in the same runtime where your application will run. If you are migrating an older codebase, start by isolating compatibility code so the rest of your project can move forward safely.

A good next step is to learn how Node.js decides whether a file is ESM or CommonJS, especially the role of package.json, file extensions, and the exports field.