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 moduleconst 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 exportimport 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.cjsmodule.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.mjsconst 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">
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.