VVersions.dev

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.

Pattern modernizationDifficulty: moderateEffort: 1–4 days depending on package sizemedium risk

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

CommonJSESM equivalent
const x = require('x')import x from 'x'
module.exports = fnexport default fn
exports.name = fnexport { fn as name }
__dirname / __filenameimport { fileURLToPath } from 'node:url' + import.meta.url
require('./mod')import './mod.js' (extension required)
conditional require()await import('...') (dynamic, async)

Step-by-step conversion

  1. Set "type": "module" in package.json (and add an "exports" map describing the entry points).
  2. Rewrite require/module.exports to import/export across the package.
  3. Replace __dirname/__filename using fileURLToPath(import.meta.url).
  4. Add file extensions to every relative import.
  5. Convert conditional or lazy require() calls to dynamic await import().
  6. 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

Official sources

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 --version
  • npm ci
  • npm rebuild
  • npm test
  • npm 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.