Quick Links

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.