Migrate Node.js CommonJS to ESM
Moving a Node package from CommonJS to ESM means setting "type": "module" and replacing require/module.exports with import/export. The sharp edges are __dirname/__filename, the loss of synchronous require, mandatory file extensions in relative imports, and dual-package hazards.
Last verified · Updated May 22, 2026
Converting a Node package from CommonJS to ESM is mechanical but unforgiving: set "type": "module", switch require/module.exports to import/export, and handle the CommonJS conveniences ESM drops. Do it package by package — the module system is decided at the package boundary, not per file.
What changes when you set type: module
Adding "type": "module" to package.json makes Node load every .js file in that package as an ES module. require and module.exports stop working, the __dirname and __filename globals disappear, relative imports must include the file extension, and there is no synchronous require — code that loaded modules conditionally must use dynamic import(). Files that must stay CommonJS can keep a .cjs extension.
Transformation rules
| CommonJS | ESM equivalent |
|---|---|
| const x = require('x') | import x from 'x' |
| module.exports = fn | export default fn |
| exports.name = fn | export { fn as name } |
| __dirname / __filename | import { fileURLToPath } from 'node:url' + import.meta.url |
| require('./mod') | import './mod.js' (extension required) |
| conditional require() | await import('...') (dynamic, async) |
Step-by-step conversion
- Set "type": "module" in package.json (and add an "exports" map describing the entry points).
- Rewrite require/module.exports to import/export across the package.
- Replace __dirname/__filename using fileURLToPath(import.meta.url).
- Add file extensions to every relative import.
- Convert conditional or lazy require() calls to dynamic await import().
- Rename any file that must remain CommonJS to .cjs.
Recreating __dirname in ESM
import.meta.url replaces the CommonJS globals
// CommonJS
// const path = require('node:path');
// const here = __dirname;
// ESM
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);⚠ Avoid the dual-package hazard
Shipping both a CommonJS and an ESM build of the same package can load it twice — once per format — giving two separate module instances with separate state. If you must publish both, use a single source of truth and a careful "exports" map so require() and import() resolve to the same instance where it matters (e.g. singletons).
Conversion checklist
- "type": "module" set and an "exports" map describes the package entry points
- No require/module.exports remain except in intentionally-named .cjs files
- __dirname/__filename recreated via import.meta.url where needed
- Every relative import includes its file extension
- Conditional require() calls converted to dynamic import()
- No dual-package double-loading of stateful singletons
Related paths
- Node.js 16 to Node.js 20 Migration Guide — Earlier step in the upgrade chain
- Node.js 18 to Node.js 20 Migration Guide — Earlier step in the upgrade chain
- Upgrade to Node.js 20 — Earlier step in the upgrade chain
- Fix Node.js Dependency Errors — Related workflow
Official sources
- Node.js 20 is now available — nodejs.org (reliability 98%)
- Node.js ECMAScript Modules — nodejs.org (reliability 98%)
Copy-ready AI prompts
Structured prompts for an AI coding assistant. Inspect first, then execute incrementally, and keep a human in the review loop.
Repo inspection: Repo inspection prompt
You are helping with a Node.js migration: convert a Node.js package from CommonJS to ESM.
Do not edit files yet. First inspect the repository and report:
1. The current Node version targets: the "engines.node" field in package.json, the .nvmrc / .node-version file, and the node-version used in CI (.github/workflows/*.yml) and any Dockerfile base image.
2. The module system: whether package.json sets "type": "module", and the mix of .js / .cjs / .mjs files.
3. Native modules (packages with binding.gyp or install scripts) that must be rebuilt against the new ABI.
4. Usage of deprecated or removed APIs surfaced by the new major's deprecations (e.g. legacy url.parse patterns, the old assert API, removed crypto/buffer constructors).
5. The install, build, and test commands and whether the lockfile is committed.
Return: a risk summary, the highest-risk files and dependencies, a suggested migration order, the commands to run before editing, and any questions that need human confirmation.Safety: Inspection only. The agent must not modify files in this step.
Works with Claude Code, Cursor, GitHub Copilot.
Migration execution: Migration execution prompt
Perform the migration (convert a Node.js package from CommonJS to ESM) one concern at a time.
Work in this order and pause for review after each: (1) bump "engines.node" in package.json and pin the new version in .nvmrc, (2) update every CI matrix entry and Docker base image to the new Node version, (3) reinstall and rebuild native modules with `npm rebuild`, (4) run the test suite and triage runtime failures, (5) replace any deprecated/removed APIs surfaced during inspection.
After each step run the project's install and test commands and report results before continuing. Do not bundle unrelated refactors or upgrade dependencies that are not required by the Node bump.Safety: Apply changes incrementally and keep each step reviewable. Rebuild native modules before assuming a failure is a code problem.
Works with Claude Code, Cursor, GitHub Copilot.
Test plan
Commands
node --versionnpm cinpm rebuildnpm testnpm run build
Manual checks
- Native modules: confirm packages with native bindings load without ABI/version errors at runtime.
- Startup: boot the app on the new Node version and watch for new deprecation warnings.
- Globals: verify code relying on built-in fetch, the test runner, or other newly-stable APIs behaves the same locally and in CI.
Regression risks
- Native addons compiled against the old ABI failing to load until rebuilt.
- Deprecated APIs removed in the new major breaking code paths not covered by tests.
- Dependencies that declare an engines range excluding the new Node version.
Acceptance criteria
- Install, build, and the full test suite pass on the target Node version.
- CI, .nvmrc, and the Docker image all reference the same Node version.
- No new Node deprecation warnings appear in the test output.