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:
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:
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”.
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:
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:
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:
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:
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:
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