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
// 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);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
Official sources
Backs the breaking-change and migration-step claims.
Backs the breaking-change and migration-step claims.
Copy-ready AI prompts
Structured prompts for an AI coding assistant. Inspect first, then execute incrementally, and keep a human in the review loop.
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
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.