From CommonJS to ES Modules: How to modernize your Node.js app
Tags: node, javascript, npm
Published: 2021-04-07 11:13:00

I recently ported a midsized, vanilla Node.js app from CommonJS to ES Modules. Here’s what I learned and how you can modernize your project step by step.

## What’s the difference between CommonJS and ES Modules?

While CommonJS is de facto everywhere in the Node.js universe, it’s safe to say that ES Modules are the future. They’re now [the official standard](https://tc39.es/ecma262/#sec-modules) format to package JavaScript code for reuse. No matter if in the browser or in Node.js, both systems allow you to import and export code from different files, making it possible to split your project into junks of self-contained functionality.

```js
// CJS
const utils = require('../utils')
const funcA = () => {}
const funcB = () => {}
module.exports = { funcA, funcB }

// ESM
import { utilA } from '../utils/index.js'
export const funcA = () => {}
export const funcB = () => {}
```

You might already use ES Modules in the browser (probably with the help of webpack or a module bundler of your choice). I therefore won’t go into further details. Mahdhi Rezvi has [a great blog post](https://blog.bitsrc.io/understanding-es-modules-in-javascript-a28fec420f73) explaining the differences of both systems in depth if you aren’t familiar with them. What matters for this guide is that the current version of Node.js has a solid support for ES Modules. The feature isn’t hidden behind a flag anymore and the official docs are marking ECMAScript modules as “stable”.

It’s time to bring your app into the future of JS development 🚀

## Tips along the way

- Most changes are small, but there’re a lot of them. It will take a while to adjust all exports and imports and your code won’t run in the meantime. You shouldn’t start this task at the end of the day.
- I recommend to use [prettier](https://prettier.io/) and [eslint](https://eslint.org/). It’s optional, but it will make your life a lot easier. We need to modify every single file and those tools help us to keep the formatting consistent, while also checking errors we overlooked. Especially the eslint plugin “[eslint-plugin-import](https://github.com/benmosher/eslint-plugin-import)” has rules that are relevant for us. Rules that verify our imports. Make sure to configure at least [import/no-unresolved](https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-unresolved.md), [import/no-commonjs](https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-commonjs.md) and [import/extensions](https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/extensions.md).

```json
{
  ...
  "plugins": ["import"],
  "rules": {
    "import/no-unresolved": 2,
    "import/no-commonjs": 2,
    "import/extensions": [2, "ignorePackages"]
  }
}
```

## Step by step: From CommonJS to ES Modules

### 1) Prepare your code

Everything is easier when you and your code are prepared.

- Check that your Git working copy is empty, start a new branch, update your dependencies and ensure that your tests are succeeding.
- Install the latest Node.js version (at least [the current LTS](https://nodejs.org/en/download/)) and verify that your app is running smoothly after the update.

Commit all changes, brew yourself a coffee and take a deep breath. It’s showtime 🧑‍💻

### 2) Update the package.json

Tell Node.js that all files are ES Modules by adding `"type": "module"` to the `package.json`. You can alternatively use the `.mjs` file extension for all your files, but I prefer the former.

```json
{
  "name": example",
  "version": "1.0.0",
  "type": "module",
  ...
}
```

### 3) Convert all imports and exports

You can do this by hand when your project only has a few files, but it’s a painful process for any midsized Node.js app.

[cjs-to-es6](https://github.com/nolanlawson/cjs-to-es6) is a CLI that converts JavaScript files from CommonJS to ES6 Modules. It’s unmaintained, but still the best tool I could find. It successfully ported around 80% of my files. That’s fine. Both modules systems are different and not everything can be converted 1:1.

Run the tool with the verbose flag to see which files failed: `cjs-to-es6 --verbose src/`.

### 4) Fix imports and exports

As already mentioned—Both modules systems are different. It’s impossible to convert all files automatically. While cjs-to-es6 is a big help, it’s still necessary to look at each file individually. Here’re a few issues I had and how I fixed them:

#### File extensions

cjs-to-es6 converts `const package = require('package')` to `import package from 'package'` and `const localFile = require('../utils/localFile')` to `import localFile from '../utils/localFile'`. That’s fine. At least for the external package.

Local file imports require a file extension. Every import must end with `.js` (or `.mjs` depending on your choice). You might haven’t seen this before, because it’s not necessary in the browser (when using a module bundler), but this is how ES Modules work. File imports are URLs.

Do this:

```js
import something from './something.js'
```

Don't do this:

```js
import something from './something'
```

#### Directory indexes

Directory indexes must be fully specified. Importing `../utils` won’t magically import the `index.js` anymore.

Do this:

```js
import * as utils from '../utils/index.js'
```

Don’t do this:

```js
import * as utils from '../utils'
```

#### Object exports

It was common to export an object of functions when a file had multiple functions to expose. You can still export objects, but this isn’t the correct way anymore. Instead, add `export` in front of every function or variable you want to export. This has the advantage that you can now import them individually via `import { funcA, funcB } from '../utils/index.js'` or all at once via `import * as utils from '../utils/index.js'`.

Do this:

```js
export const funcA = () => {}
export const funcB = () => {}
```

Don’t do this:

```js
const funcA = () => {}
const funcB = () => {}

export default {
  funcA,
  funcB
}
```

#### Broken imports of external modules

You might need to adjust the import of some external npm packages depending on how they export their code. Checking the GitHub repo for existing issues usually does the job. Sometimes it can also help to try a few common ESM imports to see where the code you’re looking for is hidden behind.

In my case it only got confusing when the module exported an object (see “Object exports”). In this case you can’t import a property or function directly via `import { funcA } from 'package'`. You instead need to import the object and access the function you’re looking for later on.

This works when the export is an object:

```js
import signale from 'signale'
const instance = new signale.Signale()
```

This won’t:

```js
import { Signale } from 'signale'
import * as signale from 'signale'
```

#### JSON imports

This made me a bit sad. It’s currently not possible to import JSON files, but there’re three ways to get around this problem:

- Use `fs` to read the content of a JSON file and parse it using `JSON.parse`
- Use JS modules instead of JSON and export an object
- Use the experimental `--experimental-json-modules` Node.js flag

#### ESLint

I had to rename my `eslintrc.js` to `eslintrc.cjs`, because ESLint [doesn’t support ES Modules](https://github.com/eslint/eslint/issues/12593), yet. Using a JSON instead of a JS configuration also does the job

> Note that we can’t support .mjs configuration files yet due to ESLint currently being synchronous in nature. — [kaicataldo](https://github.com/kaicataldo)

#### NYC Code Coverage

Are you using `nyc` (the Istanbul command line interface that reports the code covered by your tests)? It’s not ready for the future, yet. The good thing is that [V8](https://v8.dev/) (the JavaScript engine behind Node.js) has code coverage built-in. You can take advantage of it using [c8](https://github.com/bcoe/c8). A CLI similar to nyc. And because it relies on the build-in V8 code coverage it always works with the newest syntax supported by Node.js.

#### Miscellaneous

I’m sure that you will run into specific issues not covered by this guide. Every code is different. Make sure to take a look [at the Node.js documentation](https://nodejs.org/api/esm.html) before searching the web. It’s up-to-date and contains everything you need to know on one page.

### 5) Verify your changes

It’s time to check if everything went well. Use prettier to clean up your code and let eslint check your imports (this is where [eslint-plugin-import](https://github.com/benmosher/eslint-plugin-import) is a big help). Run your app to see if there’re obvious errors. I’m sure that your app won’t launch on the first try. There’s always a file that has been overlooked 🙄

## Congratulation

You successfully ported your Node.js app to ES Modules 👏 I hope this article made the process a bit easier. Let me know what you think and what issues bothered you during the conversion!