# JavaScript ESM & CommonJS Interop: Importing Between Module Systems

Source: https://devdocs10.com/articles/javascript/js-esm-cjs-interop  
By Murat Bo · Updated 2026-10-17 · DevDocs10 (https://devdocs10.com)

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

```bash
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.

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