Migrate a Node.js project to ESM

Friday, Nov 12 2021

To use an ESM Node.js module in your own, you need to use import. But node can only use import inside ESM modules, so you need to convert your own CommonJS modules to ESM. Writing import and converting it to require() with Babel does not count! Otherwise you will get an error like this:

SyntaxError: Cannot use import statement outside a module

So, how do you migrate your code to use import instead of require()? First, use at least Node.js 12.17 so that ESM modules are available without the --experimental flag. Next, get your own code to run as an ESM module. Either rename your files to the .mjs extension or add type: module at the top level in package.json. With the first option only the renamed files run as ESM modules. With the second option, you need to rename to .cjs the files you wish to run as CommonJS modules.

The next issue is that you cannot use the global require() inside an ESM module. Node.js will exit and print an error like:

ReferenceError: require is not defined in ES module scope, you can use import instead

So in order to be able to import an ESM-only module, you need to replace every single require() call, even those that import CommonJS modules.

For top-level require(), there’s just one issue that is likely to bite you: while require() supports a directory name, import only accepts the path to a single file. When use require() with a directory name, require() imports the file named index.js in that directory. So you need to replace

const myModule = require('../directory');

with

import myModule from '../directory/index.js';

If the directory contains a package.json file, require will look at the package.json in that directory to find which file to import, so you need to replace the directory name with the file indicated in package.json. Usually it’s the file in the main field.

If you use dynamic requires (hopefully you are in the minority, but for some framework-like tools it is the sad truth as they import different modules depending on the environment), you can replace them with dynamic import(). The problem is that import() is async which will make your function change return type to a Promise. This propagates the async up the function call chain, and can lead to pretty invasive API changes depending on how your code uses dynamic require(). You might be forced to use top-level await, but ESLint does not support it out of the box.

To work around this issue you can use createRequire(). This function takes the current module URL (import.meta.url) and returns a function that you can use to synchronously include other modules. This avoids having to propagate async functions everywhere.

The final issue is often related to dynamic imports and it is the usage of __dirname variable. In CommonJS modules, it gives you the absolute path of the directory containing the currently executing file, so it is useful to be able to include files relative to the current file no matter from which directory the node process starts. __dirname does not work in ESM modules. You will get an error like:

__dirname is not defined

To solve this you’ve got two possibilities. Both use import.meta.url. This is a special variable that’s available inside ESM modules which contains the URL of the module file. The best solution in most cases is to replace the file path constructed with __dirname with an URL constructed with import.meta.url, as many file APIs have been updated to accept an URL. This is less verbose. The alternative is to build the exact equivalent of __dirname from import.meta.url. The recipe for doing so is the first search result on many search engines. To avoid an error in ESLint with import.meta.url you need to change the language level to 2021 in the ESLint configuration.

The three main problems when converting a CommonJS module to ESM are directory imports, require() calls and __dirname usage. If your code base uses a lot of require() calls to dynamically constructed paths, you might be in for a lot of work. Good luck!