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 usually import CommonJS modules, but CommonJS cannot synchronously require() most ESM modules. The safest mental model is: ESM can often consume CommonJS, while CommonJS often needs dynamic import() to load ESM.
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 or Core Idea
There are two main module styles to compare.
ESM syntax
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
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 cannot use plain synchronous require() in the same way, because ESM loading is asynchronous under the hood.
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.
// math.cjs
function multiply(a, b) {
return a * b;
}
module.exports = multiply;
// app.mjs
import multiply from "./math.cjs";
console.log(multiply(2, 4)); // 8This 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.
// 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 startedIn 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
A CommonJS file cannot normally use require() to load an ESM file. Instead, it must use dynamic import(), which returns a promise.
// 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.
// 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)); // 27This pattern is common when moving a utility library from CommonJS to ESM gradually.
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
CommonJS code often tries to load an ESM file the same way it loads older packages. That breaks because ESM is not designed to be synchronously loaded with require().
Problem: Node.js typically throws ERR_REQUIRE_ESM or a similar module-loading error when you try to require() an ES module.
// app.cjs
const logger = require("./logger.mjs");Fix: Use dynamic import() from CommonJS.
// app.cjs
(async () => {
const logger = await import("./logger.mjs");
console.log(logger.log("hello"));
})();The corrected version works because import() loads ESM asynchronously, which matches the module system's behavior.
Mistake 2: Expecting named imports from a CommonJS export object
Many developers assume CommonJS properties become named exports automatically in ESM. That is not the safest assumption and often leads to confusing import errors or undefined values.
Problem: The module does not actually define ESM named exports, so importing them as if it did can fail or behave unexpectedly depending on the package and loader.
// shapes.cjs
module.exports = {
square(x) {
return x * x;
}
};
// app.mjs
import { square } from "./shapes.cjs";Fix: Import the CommonJS default value and read its properties.
// app.mjs
import shapes from "./shapes.cjs";
console.log(shapes.square(5));This works because the exported object is what ESM receives, not a set of guaranteed named bindings.
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.
// 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.
// 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.
// 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.
// 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.
// 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
- CommonJS cannot synchronously import ESM with require(); it must 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.
// 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:
$19.50
Item: NotebookThis 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 usually needs dynamic import() to load ESM.
- 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:
hello
HELLOHint: export an object from CommonJS, then read its methods from the ESM default import.
// 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 usually needs dynamic import() to load ESM.
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.