DEV Community

Wilson Xu
Wilson Xu

Posted on

How to Migrate Your CLI Tool from CommonJS to ESM

How to Migrate Your CLI Tool from CommonJS to ESM

The JavaScript ecosystem is moving to ES Modules. Major packages like chalk, ora, and execa have dropped CommonJS support entirely — if you require() them, you get an error. For CLI tool authors, this means migrating to ESM or getting stuck on outdated dependency versions.

This guide walks through the migration step by step, covering the non-obvious gotchas that trip up most developers.

Why Migrate Now

Three reasons ESM migration can't wait:

  1. Dependencies are ESM-only: chalk v5, ora v7, execa v8, and many others only support import
  2. Top-level await: ESM lets you use await at the top level — no more wrapping everything in async IIFEs
  3. Tree shaking: ESM enables better bundling and dead code elimination

Step 1: Update package.json

{
  "type": "module"
}
Enter fullscreen mode Exit fullscreen mode

This single line changes how Node.js interprets .js files in your project — from CommonJS to ESM. Every .js file now uses import/export instead of require/module.exports.

Step 2: Convert require() to import

// Before (CommonJS)
const { program } = require('commander');
const chalk = require('chalk');
const { readFile } = require('fs').promises;
const path = require('path');

// After (ESM)
import { program } from 'commander';
import chalk from 'chalk';
import { readFile } from 'node:fs/promises';
import { join, resolve, dirname } from 'node:path';
Enter fullscreen mode Exit fullscreen mode

The node: Prefix

Use the node: prefix for built-in modules. It's not required but it's the modern convention and makes it clear which modules are built-in vs npm packages:

import { readFile } from 'node:fs/promises';    // ✓ Clear
import { join } from 'node:path';                // ✓ Clear
import { createServer } from 'node:http';        // ✓ Clear
Enter fullscreen mode Exit fullscreen mode

Step 3: Fix __dirname and __filename

The biggest gotcha: __dirname and __filename don't exist in ESM.

// CommonJS (works)
const configPath = path.join(__dirname, '../config.json');

// ESM (replacement)
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const configPath = join(__dirname, '../config.json');
Enter fullscreen mode Exit fullscreen mode

Or use import.meta.dirname (Node.js 21.2+):

// Node.js 21.2+ only
const configPath = join(import.meta.dirname, '../config.json');
Enter fullscreen mode Exit fullscreen mode

Step 4: Fix Dynamic Imports

// CommonJS
const plugin = require(pluginPath);

// ESM
const plugin = await import(pluginPath);
// Note: dynamic import returns a module object
const { default: pluginDefault } = await import(pluginPath);
Enter fullscreen mode Exit fullscreen mode

Step 5: Fix JSON Imports

// CommonJS
const pkg = require('./package.json');

// ESM Option 1: readFile
import { readFile } from 'node:fs/promises';
const pkg = JSON.parse(await readFile(new URL('./package.json', import.meta.url), 'utf-8'));

// ESM Option 2: import assertion (Node.js 17.5+)
import pkg from './package.json' with { type: 'json' };

// ESM Option 3: createRequire (compatibility escape hatch)
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const pkg = require('./package.json');
Enter fullscreen mode Exit fullscreen mode

Step 6: Fix module.exports

// CommonJS
module.exports = { runAudit, formatReport };
module.exports.default = runAudit;

// ESM
export { runAudit, formatReport };
export default runAudit;
Enter fullscreen mode Exit fullscreen mode

Step 7: Update the Shebang

The shebang line stays the same:

#!/usr/bin/env node
Enter fullscreen mode Exit fullscreen mode

But make sure your bin entry in package.json points to a .js file (not .mjs):

{
  "type": "module",
  "bin": {
    "mytool": "./bin/mytool.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 8: Fix Tests

If you use Vitest, you're already fine — it supports ESM natively. For Jest:

{
  "jest": {
    "transform": {},
    "extensionsToTreatAsEsm": [".ts"]
  }
}
Enter fullscreen mode Exit fullscreen mode

Or just switch to Vitest:

npm remove jest
npm install -D vitest
Enter fullscreen mode Exit fullscreen mode
{
  "scripts": {
    "test": "vitest run"
  }
}
Enter fullscreen mode Exit fullscreen mode

Common Migration Errors and Fixes

"require is not defined in ES module scope"

You have a require() call in a .js file with "type": "module". Convert to import.

"Cannot use import statement outside a module"

Your file uses import but "type": "module" is missing from package.json.

"__dirname is not defined"

Use import.meta.dirname (Node.js 21.2+) or the fileURLToPath pattern.

"ERR_REQUIRE_ESM"

You're trying to require() an ESM-only package. Convert to dynamic import() or add "type": "module".

"Named export 'X' not found"

Some CJS packages don't have named exports in ESM. Use default import:

// Instead of:
import { something } from 'cjs-package';

// Use:
import cjsPackage from 'cjs-package';
const { something } = cjsPackage;
Enter fullscreen mode Exit fullscreen mode

Migration Checklist

  • [ ] Add "type": "module" to package.json
  • [ ] Convert all require() to import
  • [ ] Convert all module.exports to export
  • [ ] Replace __dirname/__filename with import.meta equivalents
  • [ ] Fix JSON imports
  • [ ] Fix dynamic requires to dynamic imports
  • [ ] Update test runner config
  • [ ] Use node: prefix for built-in modules
  • [ ] Test the shebang line still works
  • [ ] Verify npm pack --dry-run output is correct
  • [ ] Test on Node.js 18, 20, and 22

Should You Support Both?

For CLI tools: no. Just go ESM. CLI tools are end-user executables, not libraries. Users don't require() your CLI — they run it. There's no backward compatibility concern.

For libraries: consider dual publishing with conditional exports:

{
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

But for CLI tools, this complexity isn't needed.

Conclusion

ESM migration for CLI tools is a one-time cost that takes 30-60 minutes for most projects. The checklist above covers every gotcha. Once you're on ESM, you get top-level await, access to modern ESM-only packages, and cleaner import syntax. Just do it.


Wilson Xu has migrated 16+ npm CLI tools to ESM. Find them at npmjs.com/~chengyixu.

Top comments (0)