Sitemap

ESM-Only Libraries Are Here: A Migration Playbook for CommonJS-Heavy Node Codebases

The error that started it all

If you have maintained a Node.js service for more than a couple of years, you have probably met this message after a routine npm update:

Error [ERR_REQUIRE_ESM]: require() of ES Module /app/node_modules/chalk/source/index.js
from /app/src/logger.js not supported.
Instead change the require of index.js to a dynamic import() which is available in all CommonJS modules.

You did nothing wrong. Your code still says const chalk = require('chalk'), exactly as it did yesterday. What changed is the library: its new major version stopped shipping CommonJS and now ships only ECMAScript Modules (ESM). Packages like chalk v5, node-fetch v3, got v12+, nanoid v4+, p-queue v7+, execa, and recent major versions of uuid all made this move, and the list grows every month.

For years, the honest advice was “pin the old version and wait.” That advice has now expired. Two things changed in 2025–2026:

  1. Node.js can require() ES modules. The feature known as require(esm) is enabled by default on every supported Node.js line and is now marked stable. A CommonJS file can load most ESM-only packages with a plain require().
  2. Old versions are out of support. Pinning chalk@4 forever means pinning a package that will never get another security fix. The "just pin it" strategy now carries real risk.

This article is a playbook. It explains the theory from scratch, so a beginner can follow it, and then gives a step-by-step plan, with code, for teams that have hundreds of thousands of lines of CommonJS and cannot rewrite everything in one sprint.

What you will learn:

  • What CommonJS and ESM actually are, and why they behave differently under the hood
  • Why ERR_REQUIRE_ESM existed, and how require(esm) removes most of it
  • The three remaining traps: top-level await, the .default wrapper, and tooling
  • A four-phase migration playbook: audit, choose a strategy, migrate incrementally, harden tooling
  • How to publish your own internal libraries so they work for every consumer

All the diagrams in this post were created specifically for it. All code examples target Node.js 22.12 or newer.

Press enter or click to view image in full size

Part 1: CommonJS and ESM, explained from zero

A module is simply a file that keeps its variables private and chooses what to share with other files. JavaScript did not have modules built into the language for most of its life, so Node.js invented its own system in 2009. That system is CommonJS (CJS). In 2015, the language itself gained an official module system: ECMAScript Modules (ESM). Node.js has supported both since version 12, and that is the root of every problem in this article.

CommonJS: the original Node.js way

Here is a tiny CommonJS module and a file that uses it:

// math.js  (CommonJS)
const PI = 3.14159; // private: nobody outside can see this

function area(r) {
return PI * r * r;
}
module.exports = { area }; // public: this object is what require() returns
// app.js  (CommonJS)
const { area } = require('./math'); // runs math.js right now, returns module.exports
console.log(area(2)); // 12.56636

Three things are happening behind the scenes, and they explain almost every migration problem later on:

  1. require() is an ordinary function call. When Node reaches line 1 of app.js, it pauses, finds math.js on disk, reads it, runs it from top to bottom, and hands back whatever ended up in module.exports. Only then does line 2 run. This is synchronous loading.
  2. Node wraps every CJS file in a hidden function. Before running your code, Node places it inside function (exports, require, module, __filename, __dirname) { ... }. That is why require, module, __dirname and __filename seem to exist "for free" in CommonJS. They are not globals. They are parameters of that invisible wrapper.
  3. Exports are a snapshot of an object. module.exports is a plain object. If math.js later changes a variable, code that already destructured { area } keeps the old value.

Because require() is just a function, you can call it anywhere: inside an if, inside a function, with a computed string. That flexibility is convenient, but it also means tools cannot know what a file depends on until the code actually runs.

ES Modules: the language standard

The same example written as ESM:

// math.mjs  (ESM)
const PI = 3.14159;

export function area(r) { // named export
return PI * r * r;
}
export default { area }; // optional default export
// app.mjs  (ESM)
import { area } from './math.mjs'; // a declaration, not a function call
console.log(area(2));

This looks like a syntax change, but the engine treats it completely differently:

  • import and export are declarations. They must sit at the top level of the file, and the module name must be a fixed string. The engine can read every dependency without executing a single line.
  • Bindings are live. area in app.mjs is not a copy. It is a read-only window onto the variable inside math.mjs. If the exporting module updates it, importers see the new value.
  • Strict mode is always on, and there is no hidden wrapper. That is why __dirname, require and module do not exist inside ESM.
  • File paths must be complete. import './math' fails in Node. You must write ./math.js or ./math.mjs. ESM follows the same rules as browsers, which never guess file extensions.

The real difference: how loading happens

The diagram below is the most important picture in this article.

Press enter or click to view image in full size

CommonJS loads and runs at the same time. Each require() reads one file and executes it immediately. When that file calls require() itself, the process repeats, nested inside the first call. It is one long, synchronous chain.

ESM works in three separate phases across the whole dependency graph:

  1. Construction. Node finds every file reachable from the entry point, downloads or reads it, and parses it. It records every import and export, but runs nothing.
  2. Instantiation. Node connects each import to the matching export as a live binding. If you import a name that does not exist, the error appears here, before any of your code runs.
  3. Evaluation. Node finally executes each module body, starting from the deepest dependency and moving up.

Phase 3 is the key. ESM allows top-level await: an await written directly in the module body, outside any function. When a module does that, its evaluation pauses until the promise settles, so the whole load becomes asynchronous. CommonJS's require() must return a value immediately. It cannot wait.

How Node decides which system a file uses

Node does not guess from the code alone. It follows these rules, in order:

Press enter or click to view image in full size

The phrase “nearest package.json" matters. Node walks up from the file's folder until it finds one. That means you can place a tiny package.json with just { "type": "module" } inside one subfolder and turn only that folder into ESM. We will use this trick in the migration playbook.

Beginner takeaway: .mjs and .cjs are explicit and never lie. Plain .js is decided by the nearest package.json. When in doubt, be explicit.

Part 2: Why the two systems clash

With the loading model in mind, the compatibility rules stop looking random. There are four possible directions, and for years one of them was blocked.

Direction 1: ESM importing CommonJS (always worked)

An ES module can import a CommonJS file because CommonJS is synchronous. Waiting for something that finishes immediately is never a problem.

// app.mjs (ESM) importing a CJS package such as express
import express from 'express'; // default import = module.exports
import { Router } from 'express'; // named import: works only if Node can detect it

The default import always gives you module.exports. Named imports are a best effort. Node scans the CommonJS source with a small static analyzer (called cjs-module-lexer) looking for patterns like exports.Router = .... If a package builds its exports dynamically, the analyzer cannot see them, and you get a SyntaxError saying the named export was not found. The safe fallback is:

import pkg from 'some-cjs-lib';
const { helper } = pkg; // destructure from the default import

Direction 2: CommonJS requiring ESM (blocked for years)

This is the direction that hurt. require() is synchronous and must return a value right now. ESM evaluation was treated as potentially asynchronous because of top-level await. A synchronous function cannot wait on an asynchronous one, so Node simply refused:

// logger.js (CJS) on Node 18 or Node 22.11
const chalk = require('chalk'); // chalk v5 is ESM-only
// → Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported.

The only escape hatch was dynamic import(), which works inside CommonJS but returns a promise:

// logger.js (CJS) — the old workaround
let chalkPromise;

function getChalk() {
// import() is async, so we cache the promise and reuse it
chalkPromise ??= import('chalk').then((mod) => mod.default);
return chalkPromise;
}
async function logError(message) {
const chalk = await getChalk();
console.error(chalk.red(message));
}
module.exports = { logError };

Read that code carefully, because it shows why the workaround spreads like a virus. logError used to be synchronous. Now it is async. Every caller must now await it, so every caller's caller becomes async too. One ESM-only dependency deep in the tree can force an async rewrite all the way up to your HTTP handlers. Most teams chose to pin the old CommonJS version instead.

Why the wall was not really necessary

Node contributors eventually pointed out a subtle fact: ESM is only asynchronous when the module graph actually contains top-level await. The vast majority of packages never use it. For those packages, all three phases can complete synchronously, so there is no technical reason require() cannot load them. That insight became the require(esm) feature, covered next.

The other everyday differences

Before moving on, here is the full list of differences you will hit while migrating. Keep it handy.

Press enter or click to view image in full size

Part 3: The game-changer, require(esm)

On any supported Node.js release today, CommonJS can require() an ES module synchronously, as long as that module's graph has no top-level await. The feature was marked stable in Node v25.4.0 and backported to the 24.x LTS line, according to the Node.js modules documentation and this OpenReplay timeline.

Press enter or click to view image in full size

What this means for your version policy:

  • Node 22.12+ has it on by default. This is the practical minimum for any migration in this article.
  • Node 20.19+ also has it, but Node 20 reached end-of-life on April 30, 2026. Do not plan around it.
  • Node 18 never received it. If you are still on 18, upgrading Node is step zero.

What require() actually returns

This is the part that trips people up. When you require() an ES module, you do not get the default export directly. You get the module namespace object, the same thing await import() gives you.

// point.mjs (ESM)
export default class Point {
constructor(x, y) { this.x = x; this.y = y; }
}
export function distance(a, b) {
return Math.hypot(b.x - a.x, b.y - a.y);
}
// main.js (CJS)
const mod = require('./point.mjs');
console.log(mod);
// [Module: null prototype] {
// __esModule: true,
// default: [class Point],
// distance: [Function: distance]
// }

const Point = mod.default; // the class lives on .default
const { distance } = mod; // named exports are plain properties
const p = new Point(0, 0);
console.log(distance(p, new Point(3, 4))); // 5

The extra __esModule: true flag is added for compatibility with code produced by transpilers like Babel and older TypeScript. You should not depend on it in hand-written code.

The practical consequence: when you replace an old CommonJS version of a package with its ESM-only version, const chalk = require('chalk') may now return an object with .default instead of the function itself. A defensive pattern that works for both shapes:

// Works whether the package exposes module.exports or an ESM default
const chalkModule = require('chalk');
const chalk = chalkModule.default ?? chalkModule;

The "module.exports" escape hatch (for library authors)

If you maintain an ESM package and want CommonJS users to receive a plain value, Node lets you export something under the special string name "module.exports":

// point.mjs
export function distance(a, b) { return Math.hypot(b.x - a.x, b.y - a.y); }

export default class Point {
constructor(x, y) { this.x = x; this.y = y; }
static distance = distance; // attach named helpers so CJS users keep them
}
export { Point as 'module.exports' }; // require() now returns Point itself
// consumer.js (CJS)
const Point = require('./point.mjs'); // [class Point], no .default needed
const { distance } = require('./point.mjs'); // works because of the static property

The trade-off: once you use "module.exports", CommonJS consumers only see that one value, so named exports disappear unless you attach them to it, as above.

The one hard limit: top-level await

Here is the full decision path Node follows on every require() call:

Press enter or click to view image in full size

If the required module, or anything it imports, however deep, uses top-level await, Node throws ERR_REQUIRE_ASYNC_MODULE and tells you to use import(). The word "graph" matters. Your direct dependency may be fine while a dependency three levels down adds a top-level await in a patch release.

That is not hypothetical. In April 2026, a release of lru-cache added top-level await to its ESM build. That broke CommonJS projects that reached it through a chain of ESM-only packages, including jsdom users. The maintainer reverted it, but the lesson stands: the top-level-await-free graph is now a compatibility contract.

Diagnostic tools built into Node

# Print a warning every time require() loads an ES module (find hidden usage)
node --trace-require-module=all server.js

# When ERR_REQUIRE_ASYNC_MODULE happens, print where the top-level awaits are
node --experimental-print-required-tla server.js

# Emergency switch: turn the feature off to compare behavior
node --no-require-module server.js

And you can feature-detect at runtime:

if (!process.features.require_module) {
throw new Error('This service needs Node.js 22.12+ (require(esm) support).');
}

The module-sync export condition

Node also resolves a "module-sync" condition in a package's exports map when require(esm) is available. Library authors use it to point modern Node at the ESM build for both require and import, while older runtimes fall back to a CommonJS file. You will see it in Part 8.

Part 4: Playbook step 1, audit before you touch anything

A migration fails when it starts with code changes. It succeeds when it starts with an inventory. The audit answers four questions: which Node version runs everywhere, which dependencies are ESM-only, which of them could carry top-level await, and how much of your own code depends on CommonJS-only features.

4.1 Check the runtime in every environment

Your laptop is not the problem. CI images, Docker base images, serverless runtimes and that one cron box are. Run this everywhere your code runs:

node -p "process.version + '  require(esm): ' + process.features.require_module"
# v24.11.0 require(esm): true

Then make the requirement explicit in package.json so installs warn early:

{
"engines": { "node": ">=22.12.0" }
}

Add engine-strict=true to your project's .npmrc if you want npm install to fail instead of warn on an older runtime.

4.2 Classify every dependency

The script below reads each dependency’s package.json and labels it CJS, dual (ships both), or ESM-only. It is a heuristic, but it is accurate enough to plan with. Save it as scripts/esm-audit.cjs. We use the .cjs extension so it runs as CommonJS no matter how the project is configured later.

// scripts/esm-audit.cjs
const fs = require('node:fs');
const path = require('node:path');

const root = process.cwd();
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
const deps = Object.keys({ ...pkg.dependencies });
// Collect every condition name used anywhere in an "exports" map
// e.g. { ".": { "import": "...", "require": "..." } } -> { import, require }
function conditionKeys(exportsField, keys = new Set()) {
if (exportsField && typeof exportsField === 'object') {
for (const [key, value] of Object.entries(exportsField)) {
if (!key.startsWith('.')) keys.add(key); // ".", "./sub" are paths, not conditions
conditionKeys(value, keys);
}
}
return keys;
}
function classify(meta) {
const keys = conditionKeys(meta.exports);
const speaksEsm = meta.type === 'module' || keys.has('import') || keys.has('module-sync');
const speaksCjs =
keys.has('require') ||
(meta.type !== 'module' && !keys.has('import')) ||
String(meta.main || '').endsWith('.cjs');
if (speaksEsm && speaksCjs) return 'dual';
return speaksEsm ? 'ESM-only' : 'CJS';
}
const rows = deps.map((name) => {
const file = path.join(root, 'node_modules', name, 'package.json');
const meta = JSON.parse(fs.readFileSync(file, 'utf8'));
return { name, version: meta.version, kind: classify(meta) };
});
console.table(rows.sort((a, b) => a.kind.localeCompare(b.kind)));

Run it with node scripts/esm-audit.cjs. You will get a table like this:

┌─────────┬───────────┬───────────┬────────────┐
│ (index) │ name │ version │ kind │
├─────────┼───────────┼───────────┼────────────┤
│ 0 │ 'express' │ '5.3.0' │ 'CJS' │
│ 1 │ 'zod' │ '3.25.76' │ 'dual' │
│ 2 │ 'chalk' │ '5.6.2' │ 'ESM-only' │
│ 3 │ 'nanoid' │ '5.1.16' │ 'ESM-only' │
└─────────┴───────────┴───────────┴────────────┘

How the code works. A package can declare its format in two places. The "type" field tells Node how to treat its .js files. The "exports" map lists entry points, optionally keyed by conditions such as import, require and module-sync. The recursive conditionKeys function walks that map, which can be nested, and gathers all the condition names. If a package offers a require entry, CommonJS users get a CJS file. If it only offers import or is type: module, it is ESM-only. Version numbers in the sample output are illustrative.

4.3 Find packages you held back

ESM-only releases are often the reason a dependency is stuck. Run:

npm outdated

Look for major versions you skipped, then check each changelog for words like “pure ESM” or “drop CommonJS”. Those are your migration candidates, and often your security backlog too.

4.4 Smoke-test require() for every dependency

Classification tells you what should work. This test tells you what does work on your real Node version, including the top-level await trap:

// scripts/require-smoke.cjs
const pkg = require('../package.json');

const results = Object.keys(pkg.dependencies ?? {}).map((name) => {
try {
const mod = require(name);
const shape = mod && mod.__esModule ? 'namespace (.default)' : typeof mod;
return { name, status: 'ok', shape };
} catch (err) {
return { name, status: err.code ?? 'error', shape: err.message.split('\n')[0] };
}
});
console.table(results);
process.exitCode = results.some((r) => r.status !== 'ok') ? 1 : 0;

Read the status column like this:

Here is the real output from a test project on Node 22:

┌─────────┬───────────┬────────┬────────────────────────┐
│ (index) │ name │ status │ shape │
├─────────┼───────────┼────────┼────────────────────────┤
│ 0 │ 'chalk' │ 'ok' │ 'namespace (.default)' │
│ 1 │ 'express' │ 'ok' │ 'function' │
│ 2 │ 'nanoid' │ 'ok' │ 'object' │
│ 3 │ 'zod' │ 'ok' │ 'namespace (.default)' │
└─────────┴───────────┴────────┴────────────────────────┘
  • ok with shape namespace (.default) means the package has a default export reached through .default. For ESM-only packages like chalk, that is require(esm) at work. Use .default in your code.
  • ok with shape object can also be an ESM namespace. nanoid has only named exports, so const { nanoid } = require('nanoid') just works.
  • ERR_REQUIRE_ASYNC_MODULE means a top-level await sits somewhere in that package's graph. It needs Strategy B from Part 5.
  • ERR_PACKAGE_PATH_NOT_EXPORTED usually means the package has no main entry (a CLI or types-only package). That is normally fine to ignore.

One caution: requiring a package runs its top-level code. Run this in CI or a sandbox, not against production credentials.

4.5 Measure your own CommonJS-only habits

These patterns need rewriting when a file becomes ESM. Counting them tells you how big the job is.

# Count CommonJS-only constructs in your source (ripgrep)
rg -c "__dirname|__filename" src
rg -c "require\.(resolve|cache|main)" src
rg -n "require\([^'\"]" src # dynamic require with a variable
rg -n "require\(['\"][^'\"]+\.json['\"]\)" src # JSON requires
rg -n "module\.exports\s*=\s*" src | wc -l

4.6 Write it down

Put the findings into a simple tracker. A short table like the one below becomes your migration backlog.

Press enter or click to view image in full size

Part 5: Playbook step 2, choose a strategy

There is no single correct migration. There are three sensible strategies, and the audit tells you which one fits. Most large codebases use all three over time: A today, B for a few awkward modules, and C as the long-term direction.

Press enter or click to view image in full size

Strategy A: stay CommonJS, consume ESM through require(esm)

This is the lowest-effort option and, for many services, the right final answer. You keep every file as CommonJS. You upgrade ESM-only dependencies and load them with require().

// src/lib/ids.js (still CommonJS)
const { nanoid } = require('nanoid'); // nanoid 5 is ESM-only; named export works directly

function newOrderId() {
return `ord_${nanoid(12)}`;
}
module.exports = { newOrderId };
// src/lib/logger.js (still CommonJS)
const chalkModule = require('chalk'); // chalk 5 is ESM-only with a default export
const chalk = chalkModule.default; // the function lives on .default

function warn(message) {
console.warn(chalk.yellow(`⚠ ${message}`));
}
module.exports = { warn };

Compare this with the async workaround in Part 2. warn() stays synchronous, its callers do not change, and the upgrade is a two-line diff. That is the whole point of require(esm).

Choose A when your Node floor is 22.12+, your ESM dependencies have no top-level await, and the codebase is stable or in maintenance mode.

Strategy B: create an async boundary for the awkward modules

Some modules truly need top-level await: a config loader that fetches secrets, a module that initializes a database pool, or a third-party package that ships with it. require() will never load these. The fix is to move the asynchronous work to one place, the application's startup, instead of letting async leak through the whole codebase.

// src/config.mjs (ESM, uses top-level await on purpose)
import { readFile } from 'node:fs/promises';

const raw = await readFile(new URL('../config.json', import.meta.url), 'utf8');
export const config = Object.freeze(JSON.parse(raw));
// src/bootstrap.js (CommonJS entry point)
async function main() {
// 1. Load every async ESM module ONCE, at startup
const { config } = await import('./config.mjs');

// 2. Hand the resolved values to the synchronous CommonJS world
const { createServer } = require('./server');
const app = createServer(config);
app.listen(config.port, () => console.log(`listening on ${config.port}`));
}
main().catch((err) => {
console.error('Startup failed:', err);
process.exit(1);
});
// src/server.js (CommonJS, fully synchronous, receives config as an argument)
const express = require('express');

function createServer(config) {
const app = express();
app.get('/health', (_req, res) => res.json({ ok: true, env: config.env }));
return app;
}
module.exports = { createServer };

Why this works. await import() is allowed inside CommonJS because it returns a promise, and promises can wait for top-level await. By doing it once inside main(), the rest of the application receives plain values through dependency injection (passing them as function arguments). No other function has to become async.

If you need an ESM-with-TLA module lazily, after startup, cache the promise so it is only loaded once:

// src/lib/lazy-esm.js (CommonJS helper)
const cache = new Map();

/** Load an ES module once and reuse the same promise for every caller. */
function lazyImport(specifier) {
if (!cache.has(specifier)) cache.set(specifier, import(specifier));
return cache.get(specifier);
}
module.exports = { lazyImport };

Choose B when the smoke test shows ERR_REQUIRE_ASYNC_MODULE, or your own code needs top-level await for initialization.

Strategy C: incremental full migration to ESM

If the codebase will live for years, moving it to ESM pays off. You get the language standard, static analysis that tools understand, better tree-shaking in bundlers, and no more interop questions. The key word is incremental. Because require(esm) lets CommonJS files load ESM files, you can convert one folder at a time and ship after every step. Part 6 shows exactly how.

Get Vasanthan K’s stories in your inbox

Join Medium for free to get updates from this writer.

Choose C when the codebase is actively developed, you want new code written in modern syntax, or you publish packages consumed by ESM-first tools.

Strategy D: when you cannot upgrade Node yet

If some environment is stuck below 22.12, you have two temporary options: pin the last CommonJS-compatible version of each library, or use the async import() pattern from Part 2 in the few places that need ESM. Treat both as debt with a deadline, and put the Node upgrade at the top of the backlog.

Part 6: Playbook step 3, migrate incrementally

The golden rule: never do a big-bang rewrite. Convert small pieces, keep the app shippable after every step, and let require(esm) hold the two worlds together while you work.

6.1 Leaf-first waves

A leaf is a file that does not import any of your other internal files: utilities, constants, formatters, validation schemas. Leaves are the safest place to start because nothing inside them can break when they change format. Their parents keep calling require(), which now loads ESM.

Press enter or click to view image in full size

The picture shows a typical order. Wave 1 converts utils and config. Wave 2 converts the services and routes that use them. The entry point, server.js, goes last, because CommonJS parents can load ESM children but you want to keep the entry point stable until everything below it is done.

To find your leaves automatically, tools like madge can draw your internal dependency graph:

npx madge --leaves src/          # files with no internal dependencies
npx madge --circular src/ # circular imports to untangle first

Fix circular dependencies before converting. Cycles that cross the CJS/ESM boundary are the hardest bugs in this whole process, and Node may refuse them with ERR_REQUIRE_CYCLE_MODULE.

6.2 Flip one folder at a time with a nested package.json

Remember the “nearest package.json" rule from Part 1. You do not need to rename files to .mjs. Create a one-line file named package.json inside the folder you are converting (for example src/utils/package.json):

{ "type": "module" }

Every .js file under src/utils/ is now ESM. Everything else stays CommonJS. Here is a tested, real example:

src/
├── app.js ← CommonJS (root package.json has "type": "commonjs")
└── utils/
├── package.json ← { "type": "module" }
├── defaults.json
├── format.js ← ESM now
└── slug.js ← ESM now
// src/utils/format.js (ESM)
import defaults from './defaults.json' with { type: 'json' };

export function money(cents) {
return `$${(cents / 100).toFixed(2)}`;
}
export { defaults };
// src/app.js (still CommonJS) — no changes needed to load the converted folder
const { money, defaults } = require('./utils/format.js');
console.log(money(1999), defaults.port); // $19.99 3000

Notice one change you may need in the parent: the extension. CommonJS lets you write require('./utils/format'), and that still works, but writing the full path now makes the eventual switch to import mechanical.

6.3 Keep parents unchanged with the "module.exports" trick

The biggest source of churn during migration is the .default problem. If slug.js used to do module.exports = slugify, every parent does const slugify = require('./slug'). After conversion to export default, those parents would receive a namespace object and break.

The fix is a single line in the converted file:

// src/utils/slug.js (ESM)
export default function slugify(text) {
return text.toLowerCase().trim().replace(/\s+/g, '-');
}

// Transitional: CommonJS parents get the function directly, exactly like before
export { slugify as 'module.exports' };
// src/app.js (CommonJS) — unchanged
const slugify = require('./utils/slug.js');
slugify('Hello World'); // 'hello-world'

ESM importers still use import slugify from './slug.js'. Once every parent is converted, delete the transitional line.

6.4 The conversion cookbook

Each pattern below shows the CommonJS original and its ESM replacement. Together they cover the vast majority of real-world files.

Imports and exports

// CommonJS
const fs = require('fs');
const { join } = require('path');
const db = require('./db');
module.exports = { getUser, saveUser };
module.exports.helper = helper;
// ESM
import fs from 'node:fs';
import { join } from 'node:path';
import db from './db.js'; // extension is mandatory
export { getUser, saveUser };
export { helper };

The node: prefix is optional for built-ins but makes it obvious which imports are core modules. The .js extension is not optional for relative paths.

__dirname and __filename

// CommonJS
const templates = path.join(__dirname, 'templates');
// ESM (Node 20.11+)
const templates = path.join(import.meta.dirname, 'templates');

// ESM, portable fallback for any Node version
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

import.meta.url is the current file's location as a file:// URL. fileURLToPath converts it to a normal path. Many file APIs also accept URLs directly, so new URL('./templates/', import.meta.url) is often the cleanest option.

JSON files

// CommonJS
const pkg = require('./package.json');
// ESM, using import attributes
import pkg from './package.json' with { type: 'json' };

// ESM, reading at runtime (useful when the file changes or is large)
import { readFile } from 'node:fs/promises';
const pkg = JSON.parse(await readFile(new URL('./package.json', import.meta.url), 'utf8'));

Careful: the second option uses top-level await. That makes this module impossible for CommonJS parents to require(). In a mixed codebase, prefer the import-attribute version.

require.resolve

// CommonJS
const workerPath = require.resolve('./worker.js');
// ESM: returns a file:// URL string
const workerUrl = import.meta.resolve('./worker.js');

Conditional and dynamic loading

// CommonJS: load an optional dependency only when configured
let redis = null;
if (process.env.REDIS_URL) {
redis = require('ioredis');
}
// ESM: use dynamic import(); it returns a promise of the namespace
let redis = null;
if (process.env.REDIS_URL) {
const { default: Redis } = await import('ioredis');
redis = new Redis(process.env.REDIS_URL);
}

If this lives in a module that CommonJS still require()s, wrap it in an async function instead of using top-level await, for the same reason as the JSON example.

When you truly need require inside ESM

Some old plugins or native addons expect require. Create one:

// ESM
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);

const legacyPlugin = require('./legacy-plugin.cjs');
const addon = require('./build/Release/addon.node'); // native addons load via require

“Is this file the entry point?”

// CommonJS
if (require.main === module) main();
// ESM
import { fileURLToPath } from 'node:url';
if (process.argv[1] === fileURLToPath(import.meta.url)) main();

6.5 The final flip

When most of the codebase is ESM, invert the default instead of adding more nested package.json files:

  1. Set "type": "module" in the root package.json.
  2. Rename the remaining CommonJS files to .cjs. Config files for older tools (.eslintrc.cjs, jest.config.cjs) often fall into this group.
  3. Delete the nested { "type": "module" } files, since they are now redundant.
  4. Remove the transitional export { x as 'module.exports' } lines once no CommonJS file imports those modules.
  5. Run the full test suite and the smoke script from Part 4.

6.6 Ship every wave

Each wave should be its own pull request with its own deploy. A practical rhythm for a mid-size service:

Part 7: Playbook step 4, harden your tooling

Code is rarely what breaks during an ESM migration. Tooling is: the TypeScript compiler, the test runner, and dev scripts that quietly assumed CommonJS. Fix these deliberately.

7.1 TypeScript

TypeScript has to know which module system Node will use, so it can emit the right output and check imports correctly. For Node.js projects, use the Node-aware settings:

// tsconfig.json
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "es2023",
"verbatimModuleSyntax": true,
"rewriteRelativeImportExtensions": true,
"outDir": "dist",
"strict": true
}
}

What each option does, in plain words:

  • module: nodenext makes TypeScript follow Node's real rules. It reads the same "type" field and file extensions Node does, so a .ts file compiles to ESM or CommonJS depending on where it sits. It also understands require(esm), according to the TypeScript 5.9 release notes. If you prefer a setting that never changes behavior between releases, TypeScript 5.9 added the stable module: node20.
  • verbatimModuleSyntax tells TypeScript to write your import and export statements exactly as you wrote them, and forces you to mark type-only imports with import type. That removes a whole class of "works in TS, crashes in Node" bugs. One caveat for mixed codebases: with this flag on, TypeScript files that compile to CommonJS must use `import x = require('x')` instead of `import x from 'x'`. Many teams switch it on per folder as each folder becomes ESM, or right after the final flip.
  • rewriteRelativeImportExtensions lets you write import { x } from './util.ts' and have it emitted as ./util.js. Without it, you must write ./util.js in your TypeScript source even though the file on disk is util.ts. That surprises everyone the first time, but it is correct: the import path describes the output file Node will load.

TypeScript also supports per-file formats through extensions: .mts always compiles to ESM (.mjs), and .cts always compiles to CommonJS (.cjs). They are the TypeScript twins of the .mjs/.cjs trick.

For running TypeScript directly during development, replace ts-node with tsx, which handles both module systems without extra configuration:

{
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js"
}
}

7.2 Tests

The test runner is where most teams lose a day. Know where your runner stands:

Press enter or click to view image in full size

The Jest status comes from the Jest 30.4.0 release notes, which say require(esm) is supported but still requires the experimental flag.

If you stay on Jest with a CommonJS codebase, the simplest fix for an ESM-only dependency is to let Jest transpile it. By default, Jest skips everything in node_modules. You add exceptions:

// jest.config.cjs
module.exports = {
testEnvironment: 'node',
transform: { '^.+\\.[cm]?[jt]sx?$': 'babel-jest' },
// Transform these ESM-only packages instead of skipping them
transformIgnorePatterns: ['/node_modules/(?!(chalk|nanoid|p-queue)/)'],
};

The regular expression reads as: “ignore everything inside node_modules except chalk, nanoid and p-queue". Those three get converted to CommonJS for the test run only.

If you run Jest in native ESM mode, mocking changes. jest.mock() relies on being hoisted above require() calls, and static import statements cannot be intercepted that way. Use the ESM API and a dynamic import:

// orders.test.js (ESM, run with NODE_OPTIONS=--experimental-vm-modules)
import { jest } from '@jest/globals';

// 1. Register the mock BEFORE the module under test is loaded
jest.unstable_mockModule('./payments.js', () => ({
charge: jest.fn().mockResolvedValue({ id: 'ch_123' }),
}));
// 2. Load the module under test dynamically, so it sees the mock
const { placeOrder } = await import('./orders.js');
const { charge } = await import('./payments.js');
test('charges the customer', async () => {
await placeOrder({ total: 4200 });
expect(charge).toHaveBeenCalledWith(4200);
});

The order matters. Because ESM links the whole graph before running anything (Part 1, phase 2), the mock must exist before orders.js is imported. The dynamic import() delays that load until after the mock is registered.

For new projects, or if Jest friction is high, Vitest offers an almost identical API with native ESM support, and the built-in node:test runner needs no configuration at all.

7.3 Linting

Let the linter catch mistakes before Node does:

// eslint.config.js (flat config, ESM)
import n from 'eslint-plugin-n';

export default [
n.configs['flat/recommended'],
{
rules: {
'n/no-missing-import': 'error', // catches './util' without .js
'n/file-extension-in-import': ['error', 'always'],
'no-restricted-syntax': [
'error',
{ selector: "CallExpression[callee.name='require']", message: 'Use import in ESM files.' },
],
},
},
];

Apply the require restriction only to folders you have already converted, using a files glob, so the linter tracks your migration progress.

7.4 A CI guard for the top-level-await contract

While parts of your codebase are still CommonJS, any ESM module they require() must stay free of top-level await. A tiny test enforces that rule automatically:

// test/require-graph.test.cjs  (runs with node --test)
const test = require('node:test');
const assert = require('node:assert');

// Every ESM entry point that CommonJS code still require()s
const requiredByCjs = ['../src/utils/format.js', '../src/utils/slug.js', 'chalk', 'nanoid'];
for (const specifier of requiredByCjs) {
test(`require(${specifier}) stays synchronous`, () => {
assert.doesNotThrow(() => require(specifier));
});
}

If someone adds a top-level await to format.js, or a dependency update sneaks one in, this test fails in CI with ERR_REQUIRE_ASYNC_MODULE instead of failing in production at startup.

Part 8: If you publish packages (internal or public)

Many CommonJS-heavy companies also own a set of internal npm packages: a shared logger, an auth client, a design-token library. Once your consumers run Node 22.12+, the best format for those packages is usually ESM-only.

8.1 Why dual builds are now a liability

For years, the standard advice was to ship both a CommonJS build and an ESM build. That solved the ERR_REQUIRE_ESM problem but created another one: the dual-package hazard.

Press enter or click to view image in full size

If one part of an application loads your package with require() and another part uses import, Node can load both builds. They are separate module instances with separate state. A cache filled through the CommonJS copy is empty in the ESM copy. A class exported from one copy fails instanceof checks against objects created by the other. These bugs are miserable to debug because each copy looks correct on its own.

With require(esm), CommonJS consumers load the real ES module, so there is only one instance. Shipping ESM-only removes the hazard and halves your build output.

8.2 A minimal ESM-only package.json

{
"name": "@acme/logger",
"version": "3.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": ["dist"],
"engines": { "node": ">=22.12.0" }
}

Field by field:

  • type: module makes every .js file in the package ESM.
  • exports defines the public entry points. Anything not listed is private, and consumers get ERR_PACKAGE_PATH_NOT_EXPORTED if they try to deep-import it. The types condition must come first so TypeScript finds the declarations.
  • engines documents the require(esm) requirement. Bump your major version when you make this change, since older Node users will break.

8.3 Make CommonJS consumers feel at home

If your package has a default export, CommonJS users will otherwise need .default. Apply the trick from Part 3 so require('@acme/logger') returns the logger directly:

// src/index.js
export function createLogger(name) {
return {
info: (msg) => console.log(`[${name}] ${msg}`),
error: (msg) => console.error(`[${name}] ${msg}`),
};
}

const defaultLogger = createLogger('app');
defaultLogger.createLogger = createLogger; // keep the named export reachable from CJS
export default defaultLogger;
export { defaultLogger as 'module.exports' };
// A CommonJS consumer
const logger = require('@acme/logger'); // the logger itself, no .default
const { createLogger } = require('@acme/logger');
logger.info('ready');

8.4 If you must still support older runtimes

When some consumers cannot upgrade, use the module-sync condition. Modern Node uses the ESM file for both require and import, so there is still only one instance there. Older runtimes fall back to a CommonJS build.

{
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"module-sync": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
}
}
}

Node checks conditions in the order they appear in the object, so module-sync must come before require. Treat the .cjs fallback as temporary and plan to remove it.

8.5 Treat top-level await as a breaking change

With ESM-only packages consumed through require(esm), your first top-level await, anywhere in the files your entry point imports, breaks every CommonJS consumer with ERR_REQUIRE_ASYNC_MODULE. That makes it a semver-major change. If you need async initialization, export an async function instead:

// Breaks CommonJS consumers — avoid in libraries
export const client = await connect();

// Safe for everyone - the consumer decides when to await
let clientPromise;
export function getClient() {
clientPromise ??= connect();
return clientPromise;
}

8.6 Verify before publishing

Two community tools catch most packaging mistakes:

npx publint                              # checks exports, files, and format consistency
npx @arethetypeswrong/cli --pack . # checks TypeScript types for each resolution mode

Add both to your release pipeline, along with a test that require()s and import()s the packed tarball in a clean folder.

Part 9: Error cheat sheet, checklist, and wrap-up

9.1 Errors you will meet, and what they mean

Press enter or click to view image in full size

9.2 The one-page checklist

Audit

  • Every environment (local, CI, Docker, serverless, cron) runs Node 22.12 or newer
  • engines.node set to >=22.12.0 in package.json
  • Dependencies classified as CJS, dual, or ESM-only (esm-audit.cjs)
  • require-smoke.cjs passes, or failures are understood
  • CommonJS-only patterns in your source counted

Choose

  • Strategy decided per area: A (stay CJS), B (async boundary), C (full ESM)
  • Modules that need top-level await isolated behind a startup await import()

Migrate

  • Held-back ESM-only dependencies upgraded through require(esm)
  • Circular dependencies removed before converting
  • Leaf folders converted with a nested { "type": "module" }
  • Transitional export { x as 'module.exports' } added where CJS parents expect a single value
  • Final flip: root "type": "module", leftovers renamed to .cjs, transitional exports removed

Harden

  • TypeScript on module: nodenext (or node20) with verbatimModuleSyntax
  • Test runner configured for ESM (Vitest, node:test, or Jest with transforms)
  • Lint rules enforce extensions and ban require in ESM folders
  • CI test guards the top-level-await contract for everything CJS still requires

9.3 Wrapping up

The CommonJS/ESM split was painful for a decade because of one asymmetry: CommonJS could not load ESM. On every supported Node.js release, that asymmetry is gone for any module without top-level await. That changes the migration from an all-or-nothing rewrite into an ordinary engineering project.

The practical path for a CommonJS-heavy codebase looks like this:

  1. Upgrade Node to 22.12+ and make it explicit with engines.
  2. Stop pinning old dependencies. Upgrade ESM-only packages and load them with require(), minding .default.
  3. Isolate the async exceptions behind a single await import() at startup.
  4. Migrate leaf-first, in waves, if the codebase has a long future, shipping after each wave.
  5. Guard the new contract in CI: anything CommonJS requires must stay free of top-level await.

You do not have to convert a single file today to benefit. Step 2 alone clears your security backlog of held-back majors. Everything after that can happen one folder at a time.

If this playbook helped, follow for more deep dives on Node.js architecture, and share the error you are fighting in the comments. I read them all.

Written by Vasanthan K

Senior Full Stack Engineer | Scalable Enterprise Systems | Node.js • GraphQL • React/Vue • Mongodb | Azure Cloud | 10+ Year | Gen AI enthusiast