Graphic showing the Node.js logo

A standardized way to package code as reusable modules was missing from ECMAScript for most of its history. In the absence of an integrated solution, the CommonJS (CJS) approach became the de facto standard for Node.js development. This uses require and module.exports to consume and provide pieces of code:

// Import a module
const fs = require("fs");
 
// Provide an export
module.exports = () => "Hello World";

ES2015, alternatively known as ES6, finally introduced a built-in module system of its own. ECMAScript or ES Modules (ESM) rely on the import and export syntax:

// Import a default export
import fs from "fs";
 
// Provide a default export
export default () => "Hello World";
 
// Import a named export
import {helloWorld} from "./hello-world.js";
 
// Provide a named export
export const helloWorld = () => "Hello World";

Node.js has offered on-by-default support for ESM since v16. In earlier versions you needed to use the --experimental-modules flag to activate the capability. While ES Modules are now marked as stable and ready for general use, the presence of two different module loading mechanisms means it’s challenging to consume both kinds of code in a single project.

In this article we’ll look at how to use ES Modules with Node and what you can do to maximize interoperability with CommonJS packages.

The Basics

Matters are relatively straightforward if you’re starting a new project and want to rely on ESM. As Node.js now offers full support, you can split your code into separate files and use import and export statements to access your modules.

Unfortunately you do need to make some conscious choices early on. By default Node doesn’t support import and export inside files that end with the .js extension. You can either suffix your files as .mjs, where ESM is always available, or modify your package.json file to include "type": "module".

{
    "name": "example-package",
    "type": "module",
    "dependencies": {
        "..."
    }
}

Choosing the latter route is usually more convenient for projects that will exclusively use ESM. Node uses the type field to determine the default module system for your project. That module system is always used to handle plain .js files. When not manually set, CJS is the default module system to maximize compatibility with the existing ecosystem of Node code. Files with either .cjs or .mjs extensions will always be treated as source in CJS and ESM format respectively.

Importing a CommonJS Module From ESM

You can import CJS modules within ESM files using a regular import statement:

// cjs-module.cjs
module.exports.helloWorld = () => console.log("Hello World");
 
// esm-module.mjs
import component from "./cjs-module.cjs";
component.helloWorld();

component will be resolved to the value of the CJS module’s module.exports. The example above shows how you can access named exports as object properties on the name of your import. You can also access specific exports using the ESM named import syntax:

import {helloWorld} from "./cjs-module.cjs";
helloWorld();

This works by means of a static analysis system that scans CJS files to work out the exports they provide. This approach is necessary because CJS doesn’t understand the “named export” concept. All CJS modules have one export – “named” exports are really an object with multiple property-value pairs.

Because of the nature of the static analysis process, it’s possible some rare syntax patterns might not be detected correctly. If this happens you’ll have to access the properties you need via the default export object instead.

Importing an ESM Module From CJS

Things get trickier when you want to use a new ESM module within existing CJS code. You can’t write the import statement within CJS files. However the dynamic import() syntax does work and can be paired with await to access modules relatively conveniently:

// esm-module.mjs
const helloWorld = () => console.log("Hello World");
export {helloWorld};
 
// esm-module-2.mjs
export default = () => console.log("Hello World");
 
// cjs-module.cjs
const loadHelloWorld = async () => {
    const {helloWorld} = await import("./esm-module.mjs");
    return helloWorld;
};
const helloWorld = await loadHelloWorld();
helloWorld();
 
const loadHelloWorld2 = async() => {
    const helloWorld2 = await import("./esm-module-2.mjs");
    return helloWorld2;
};
const helloWorld2 = await loadHelloWorld2();
helloWorld2();

This structure can be used to asynchronously access both the default and named exports of your ESM modules.

Retrieving the Current Module’s Path With ES Modules

ES modules don’t have access to all the familiar Node.js global variables available in CJS contexts. Besides require() and module.exports, you won’t be able to access the __dirname or __filename constants either. These are commonly used by CJS modules that need to know the path to their own file.

ESM files can read import.meta.url to obtain this information:

console.log(import.meta.url);
// file:///home/demo/module.mjs

The returned URL gives the absolute path to the current file.

Why All The Incompatibilities?

The differences between CJS and ESM run much deeper than simple syntactic changes. CJS is a synchronous system; when you require() a module, Node loads it straight from the disk and executes its content. ESM is asynchronous and splits script imports into several distinct phases. Imports are parsed, asynchronously loaded from their storage location, then executed once all their own imports have been retrieved in the same way.

ESM is engineered as a modern module loading solution with broad applications. This is why ESM modules are suitable for use in web browsers: they’re asynchronous by design, so slow networks aren’t a problem.

The asynchronous nature of ESM is also responsible for the limitations around its use in CJS code. CJS files don’t support top-level await so you can’t use import on its own:

// this...
import component from "component.mjs";
 
// ...can be seen as equivalent to this...
const component = await import("component.mjs");
 
// ...but top-level "await" isn't available in CJS

Hence you have to use the dynamic import() structure inside an async function.

Should You Switch to ES Modules?

The simple answer is yes. ES Modules are the standardized way to import and export JavaScript code. CJS gave Node a module system when the language lacked its own. Now one’s available, it’s best for the long-term health of the community to adopt the approach described by the ECMAScript standard.

ESM is also the more powerful system. Because it’s asynchronous you get dynamic imports, remote imports from URLs, and improved performance in some situations. You can reuse your modules with other JavaScript runtimes too, such as code that’s delivered straight to web browsers via <script type="module"> HTML tags.

Nonetheless migration remains challenging for many existing Node.js projects. CJS isn’t going away any time soon. In the meantime, you can ease the transition by offering both CJS and ESM exports for your own libraries. This is best achieved by writing a thin ESM wrapper around any existing CJS exports:

import demoComponent from "../cjs-component-demo.js";
import exampleComponent from "../cjs-component-example.js";
export {demoComponent, exampleComponent};

Place this file inside a new esm subdirectory in your project. Add a package.json alongside it containing the {"type": "module"} field.

Next update the package.json at your project’s root to include the following content:

{
    "exports": {
        "require": "./cjs-index.js",
        "import": "./esm/esm-index.js"
    }
}

This tells Node to provide your CJS entrypoint when users of your package require() an export. When import is used, your ESM wrapper script will be offered instead. Beware that this import map functionality brings along another side effect too: users will be limited to only the exports provided by your entrypoint file. Loading specific files via in-package paths (import demo from "example-package/path/to/file.js) is disabled when an import map is present.

Summary

The Node.js module landscape can be painful, especially when you need compatibility with both CJS and ESM. Although things have improved with the stabilization of ESM support, you still need to decide upfront which module system should be the “default” for your project. Loading code from the “other” system is possible but often uncomfortable, particularly when CJS depends on ESM.

Matters will gradually improve as more major projects switch to adopting ESM as their preferred approach. Yet with so much CJS code in existence, wholesale migration is unrealistic and it’s likely both mechanisms will be used side-by-side – with all the trade-offs that entails – for several more years to come.

If you’re starting a new library, make sure you’re distributing ESM-compatible code and try to use it as your default system where possible. This should help the Node ecosystem converge on ESM, realigning it with ES standards.

Profile Photo for James Walker James Walker
James Walker is a contributor to How-To Geek DevOps. He is the founder of Heron Web, a UK-based digital agency providing bespoke software development services to SMEs. He has experience managing complete end-to-end web development workflows, using technologies including Linux, GitLab, Docker, and Kubernetes.
Read Full Bio »