CommonJS to ES Modules Migration Playbook for Node.js
This playbook converts a Node.js project from CommonJS (require/module.exports) to ES modules (import/export). It covers how Node chooses the module system, the order of work, before/after code for each construct, interop while both formats coexist, the "exports" field for packages, test and TypeScript settings, and the exact errors you will meet. Every Node example was run on Node.js 24.
Quick answer: On a supported Node.js release (22 or 24 LTS), add "type": "module" to package.json and rename files that must stay CommonJS, such as some tool configs, to .cjs. Then convert require to import with explicit file extensions, module.exports to export, __dirname to import.meta.dirname, and JSON requires to import … with { type: "json" }. Since Node 20.19 and 22.12, require() can load ES modules that have no top-level await, so you can convert leaf files first and keep CommonJS callers working during the migration.
Difficulty: Intermediate
You'll understand this better if you know: ES module import/export syntax, CommonJS require, the basics of ESM and CommonJS interop, and package.json.
1. How Node Decides Which Module System a File Uses
| File | Treated as |
|---|---|
| *.mjs | Always an ES module |
| *.cjs | Always CommonJS |
| *.js, nearest package.json has "type": "module" | ES module |
| *.js, nearest package.json has "type": "commonjs" | CommonJS |
| *.js, no "type" field | CommonJS. If the file fails to parse as CommonJS and contains module syntax, Node re-parses it as ESM (syntax detection) |
Syntax detection is a safety net, not a configuration. When a package.json exists without a "type" field, Node 24 prints [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///…/main.js is not specified and it doesn't parse as CommonJS. Reparsing as ES module because module syntax was detected. This incurs a performance overhead. Always set "type" explicitly.
"type": "module" or .mjs? For an application or a package you are converting completely, set "type": "module" and keep .js. Use .mjs while migrating gradually inside a package that is still CommonJS, and .cjs for the few files that must remain CommonJS after the switch.
2. Prerequisites and Versions
Node 20 reached end-of-life on April 30, 2026. The supported release lines are 22 (maintenance LTS), 24 (active LTS) and 26 (current). All three support everything in this table except import.meta.main, which needs 24.2 or later.
| Feature | Available since |
|---|---|
| Import attributes (with { type: "json" }), replacing assert | 18.20, 20.10, 21.0 |
| JSON modules no longer experimental | 20.18.3, 22.12, 23.1 |
| import.meta.dirname / import.meta.filename | Added in 20.11 and 21.2. Stable since 22.16 and 24.0 |
| require() of ES modules without a flag | 20.19, 22.12, 23.0 (warning removed in 20.19, 22.13, 23.5; no longer experimental in 24.15) |
| import.meta.main | 24.2 (Stability 1.0, early development) |
Before you start, pin the minimum version in "engines", make CI use it, and get the tests green on it.
3. Step-by-Step Migration Order
- Upgrade Node locally and in CI, and set "engines": { "node": ">=22.12" }.
- Inventory CommonJS-only constructs: git grep -n -E "require\(|module\.exports|exports\.|__dirname|__filename|require\.main|require\.resolve|require\.cache". Also list config files written in CommonJS, such as .eslintrc.js, jest.config.js or webpack.config.js.
- While still on CommonJS, make specifiers explicit: change require("./config") to require("./config.js") and require("./lib") to require("./lib/index.js"). CommonJS accepts both forms, so this is a safe change on its own, and it removes the most common ESM error before it can happen.
- Protect files that must stay CommonJS: rename them to .cjs, or convert them if the tool accepts ESM configs.
- Choose a strategy. Big flip: add "type": "module" and convert every file in one change. This is best for applications and small packages. Leaf-first: convert files with no internal dependencies to .mjs one by one while CommonJS callers require() them, then flip "type" and rename .mjs back to .js at the end.
- Convert the syntax (sections 4–8).
- Update tooling: TypeScript, the test runner, linters, bundlers (section 11).
- For packages: define "exports" and decide between ESM-only and dual publishing (section 10).
- Verify with the checklist in section 14.
4. Before and After: A Small CLI Package
Before (CommonJS). package.json:
{
"name": "inventory-cli",
"version": "1.0.0",
"main": "src/index.js",
"bin": { "inventory": "src/index.js" }
}// src/config.js
const fs = require("node:fs");
const path = require("node:path");
const defaults = require("./defaults.json");
function loadConfig() {
const file = path.join(__dirname, "..", "config.local.json");
const local = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : {};
return { ...defaults, ...local };
}
module.exports = { loadConfig };// src/format.js
function formatPrice(cents, currency = "USD") {
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(cents / 100);
}
module.exports = formatPrice;// src/index.js
const { loadConfig } = require("./config");
const formatPrice = require("./format");
function run() {
const config = loadConfig();
const driver = require(`./drivers/${config.driver}`);
for (const item of driver.list()) {
console.log(item.sku, formatPrice(item.cents, config.currency));
}
}
if (require.main === module) {
run();
}
module.exports = { run, loadConfig, formatPrice };After (ES modules). package.json:
{
"name": "inventory-cli",
"version": "2.0.0",
"type": "module",
"exports": "./src/index.js",
"bin": { "inventory": "src/cli.js" },
"engines": { "node": ">=22.12" }
}// src/config.js
import fs from "node:fs";
import path from "node:path";
import defaults from "./defaults.json" with { type: "json" };
export function loadConfig() {
const file = path.join(import.meta.dirname, "..", "config.local.json");
const local = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : {};
return { ...defaults, ...local };
}// src/format.js
export function formatPrice(cents, currency = "USD") {
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(cents / 100);
}// src/drivers/memory.js
export const name = "memory";
export function list() {
return [{ sku: "A1", cents: 1999 }];
}// src/index.js (library entry: no top-level await)
import { loadConfig } from "./config.js";
import { formatPrice } from "./format.js";
export async function run() {
const config = loadConfig();
const driver = await import(`./drivers/${config.driver}.js`);
for (const item of driver.list()) {
console.log(item.sku, formatPrice(item.cents, config.currency));
}
}
export { loadConfig, formatPrice };#!/usr/bin/env node
// src/cli.js (executable entry: top-level await is fine here)
import { run } from "./index.js";
await run();Running node src/cli.js prints A1 $19.99, exactly as node src/index.js did before. Notable decisions:
- Every relative import has a file extension. ES module resolution does not guess extensions or index.js files.
- The default export of format.js became a named export. Named exports are easier for tools to analyze and look the same to CommonJS consumers.
- The require.main === module check moved into a separate cli.js. Section 13 explains why the library entry must stay free of top-level await.
- "exports" replaced "main". Only src/index.js is importable by consumers now (section 10).
5. Syntax Mapping Table
| CommonJS | ES module |
|---|---|
| const x = require("pkg") | import x from "pkg" |
| const { a, b } = require("./m") | import { a, b } from "./m.js" |
| require("./lib") (directory) | import "./lib/index.js" |
| module.exports = fn | export default fn (or a named export) |
| module.exports = { a, b } / exports.a = a | export { a, b } / export const a = … |
| __dirname / __filename | import.meta.dirname / import.meta.filename |
| require("./data.json") | import data from "./data.json" with { type: "json" } |
| require(variable) | await import(variable) |
| require.main === module | import.meta.main (24.2+), or a separate entry file |
| require.resolve("pkg") | import.meta.resolve("pkg") (returns a URL string) |
| delete require.cache[path] | No equivalent. ES modules cannot be unloaded. |
| Need require inside ESM | const require = createRequire(import.meta.url) from node:module |
ES modules are always in strict mode, and top-level this is undefined. Code that assigns to undeclared variables, such as total = 5, now throws ReferenceError: total is not defined instead of creating a global.
6. __dirname, __filename and File Paths
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
// Node 20.11+ / 21.2+
const here = import.meta.dirname;
const self = import.meta.filename;
// Portable fallback for older runtimes
const selfLegacy = fileURLToPath(import.meta.url);
const hereLegacy = path.dirname(selfLegacy);
// Or skip paths entirely: fs accepts file URLs
const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"));
console.log(here === hereLegacy, self === selfLegacy, typeof pkg.name);Do not use new URL(import.meta.url).pathname as a file path. It stays percent-encoded, so a folder named with space comes out as with%20space, and on Windows the result starts with /C:/. Use import.meta.filename or fileURLToPath.
7. JSON Imports
// Static import: the attribute is required
import config from "./config.json" with { type: "json" };
// Dynamic import: pass attributes in the options object
const { default: defaults } = await import("./defaults.json", { with: { type: "json" } });
console.log(config.port, defaults.currency);- Without the attribute, Node throws TypeError [ERR_IMPORT_ATTRIBUTE_MISSING]: Module "file:///…/config.json" needs an import attribute of "type: json".
- JSON modules have only a default export. import { port } from "./config.json" with { type: "json" } fails with SyntaxError: The requested module './config.json' does not provide an export named 'port'.
- The older assert { type: "json" } syntax is gone. Node 24 reports SyntaxError: Unexpected identifier 'assert'.
- For files that change at run time or are large, keep JSON.parse(readFileSync(…)). An imported JSON module is cached for the life of the process.
8. Dynamic and Conditional require
import() is asynchronous and returns the module namespace. Relative specifiers resolve against the importing file, as require did, but you must include the extension:
// Before (CommonJS): optional dependency
let colors = null;
try {
colors = require("picocolors");
} catch {
// not installed
}
// After (ESM)
let colorsEsm = null;
try {
colorsEsm = await import("picocolors");
} catch (error) {
if (error.code !== "ERR_MODULE_NOT_FOUND") throw error; // don't hide real errors
}If a synchronous API forced you to use require, for example a plugin loader that cannot become async, createRequire gives you a real CommonJS require function inside an ES module:
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const pkg = require("./package.json"); // CommonJS-style JSON load, synchronous
console.log(pkg.version);9. Interop While Both Formats Coexist
ESM importing CommonJS
The default import is always module.exports. Named imports also work when Node can find the export names by statically analyzing the CommonJS source:
// math.cjs: names are detectable
exports.add = (a, b) => a + b;
exports.sub = (a, b) => a - b;
// shapes.cjs: detectable (object literal)
function square(x) { return x * x; }
module.exports = { square };
// api.cjs: NOT detectable (built up dynamically)
const api = {};
api.cube = (x) => x * x * x;
module.exports = api;// app.mjs
import { add, sub } from "./math.cjs"; // works
import { square } from "./shapes.cjs"; // works
import api from "./api.cjs"; // default import always works
console.log(add(1, 2), sub(5, 3), square(3), api.cube(2));A named import from api.cjs fails at link time with SyntaxError: Named export 'cube' not found. The requested module './api.cjs' is a CommonJS module, which may not support all module.exports as named exports. The message goes on to suggest the default-import pattern shown above.
CommonJS requiring ESM
On Node 20.19+ and 22.12+, require() loads an ES module synchronously and returns its namespace object, as long as the module graph has no top-level await:
// point.mjs
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); }// app.cjs
const { default: Point, distance } = require("./point.mjs");
console.log(distance(new Point(0, 0), new Point(3, 4))); // 5The default export is on .default, not on the returned value itself. If you are converting a module whose CommonJS callers expect const fn = require("./x") to give them the function directly, export it under the string name "module.exports":
// create-server.mjs
function createServer() { return "server"; }
createServer.version = "2.0.0";
export { createServer as "module.exports" };
// In CommonJS: const createServer = require("./create-server.mjs");
// typeof createServer === "function"This is what makes leaf-first migration practical. Convert a file to .mjs, and its CommonJS callers keep calling require() unchanged.
10. package.json "exports" and Dual Packages
"exports" defines the public entry points of a package and blocks everything else:
{
"name": "price-utils",
"version": "2.0.0",
"type": "module",
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.cjs"
},
"./format": "./dist/esm/format.js",
"./package.json": "./package.json"
}
}A consumer that deep-imports an unlisted file gets Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './dist/esm/internal.js' is not defined by "exports" in …/node_modules/price-utils/package.json. Adding "exports" is a breaking change for anyone who used deep imports, so release it as a new major version.
The dual package hazard. With separate "import" and "require" builds, one process can load both copies:
import { Money } from "price-utils"; // ESM build
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const cjs = require("price-utils"); // CommonJS build
console.log(new cjs.Money(1) instanceof Money); // false: two different classesClasses, singletons, caches and module-level state are all duplicated. Your options:
- Ship ESM only if every consumer runs Node 20.19+/22.12+ and your entry has no top-level await. CommonJS consumers can then require() the single ESM build. This is the simplest option, and with Node 20 at end-of-life it is now realistic for many packages.
- Ship dual builds only for stateless code, meaning pure functions with no instanceof checks across the boundary.
- Use the "module-sync" condition, which Node matches for both import and require(), to point both at one synchronous ESM build while older tools fall back to other conditions.
The Node docs keep worked layouts in the package examples repository.
11. Tooling: TypeScript, Jest and Vitest
TypeScript
{
"compilerOptions": {
"module": "nodenext",
"target": "es2022",
"verbatimModuleSyntax": true,
"outDir": "dist"
}
}- "module": "nodenext" makes TypeScript follow Node's rules: "type" and the .mts/.cts extensions decide the output format per file, and it also sets "moduleResolution": "nodenext".
- Relative imports in files that will be ES modules need the output extension: import { loadConfig } from "./config.js", even though the source file is config.ts. Without it you get TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './config.js'?
- verbatimModuleSyntax requires import type for type-only imports. It also rejects ESM syntax in files that would compile to CommonJS, with an error that begins ECMAScript imports and exports cannot be written in a CommonJS file under 'verbatimModuleSyntax'.
Jest
Jest's ESM support is still marked experimental. You run it through Node with --experimental-vm-modules (for example node --experimental-vm-modules node_modules/jest/bin/jest.js) and either disable transforms (transform: {}) or configure them to output ESM. jest.mock hoisting does not apply to static imports. Use jest.unstable_mockModule() followed by a dynamic import() of the module under test, and import jest itself from @jest/globals. See Jest: ECMAScript Modules.
Vitest
Vitest handles ESM natively. vi.mock() calls are hoisted above imports, so the usual mocking style keeps working. It mocks only modules loaded with import, not with require. For a project that is going ESM anyway, switching the test runner is often less work than configuring Jest's experimental mode.
12. Common Errors and Fixes
| Error (Node 24) | Cause and fix |
|---|---|
| ReferenceError: require is not defined in ES module scope, you can use import instead | CommonJS code in a file Node treats as ESM. The message continues: "This file is being treated as an ES module because it has a '.js' file extension and '…/package.json' contains "type": "module". To treat it as a CommonJS script, rename it to use the '.cjs' file extension." Convert the file or rename it. |
| ReferenceError: module is not defined in ES module scope | module.exports in an ESM file, often a tool config. Use export default or rename to .cjs. |
| ReferenceError: __dirname is not defined in ES module scope | Use import.meta.dirname. |
| Error [ERR_MODULE_NOT_FOUND]: Cannot find module '…/utils' imported from …/app.js + Did you mean to import "./utils.js"? | The specifier has no extension. Add .js. |
| Error [ERR_UNSUPPORTED_DIR_IMPORT]: Directory import '…/lib' is not supported resolving ES modules imported from … | Import ./lib/index.js explicitly, or add a package "exports" entry. |
| TypeError [ERR_IMPORT_ATTRIBUTE_MISSING]: Module "file:///…/config.json" needs an import attribute of "type: json" | Add with { type: "json" }. |
| SyntaxError: Named export 'x' not found. The requested module './m.cjs' is a CommonJS module, which may not support all module.exports as named exports. | Use the default import and destructure it, or convert the module. |
| Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM graph with top-level await. Use import() instead. | A require()d ES module, or something it imports, uses top-level await. Remove it from library code, or use import(). |
| Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './x' is not defined by "exports" in … | Import a documented entry point, or (as the author) add the subpath to "exports". |
| Error [ERR_REQUIRE_ESM] | You are on a Node version without require(esm) (older than 20.19/22.12). Upgrade Node, or use import(). |
13. Pitfalls
- Top-level await in a library entry. Suppose the converted index.js from section 4 ended with if (import.meta.main) { await run(); }. The branch never runs for library users, but the await makes the whole module asynchronous. On Node 24, require()-ing that file from CommonJS fails with ERR_REQUIRE_ASYNC_MODULE. Keep top-level await in executable entry files only.
- Tool configs break when "type" flips. A .eslintrc.js or jest.config.js that uses module.exports fails with module is not defined in ES module scope. Rename it to .cjs or convert it.
- Changing the export shape by accident. module.exports = fn converted to export default fn means CommonJS consumers now get { default: fn } from require(). Use named exports, or the "module.exports" export name, to keep their code working.
- Hot-reload code that clears require.cache. ES modules cannot be unloaded. Importing with a cache-busting query string loads a new copy every time and never frees the old one.
- Mocking. Static imports are bound before your test code runs. Libraries that patch require to inject mocks do not affect import.
- Load order. Static imports are hoisted and evaluated before the importing module's body. Code that relied on running dotenv.config() before a later require must use import "dotenv/config" as the first import, or node --env-file=.env.
- The dual package hazard (section 10). Avoid separate ESM and CommonJS builds of stateful code.
14. Verification Checklist
- package.json has an explicit "type", and no MODULE_TYPELESS_PACKAGE_JSON warning appears.
- The inventory search from section 3 finds nothing outside .cjs files and intentional createRequire calls.
- Every entry point starts: node src/cli.js, the bin scripts, and the npm scripts.
- The package can be loaded both ways: node --input-type=module -e "import('your-pkg')" and node -e "require('your-pkg')", if you promise CommonJS support.
- npm pack --dry-run shows that every file referenced by "exports" is included in the tarball.
- The test suite passes under the new runner configuration, and one deliberately broken mock makes it fail.
- CI runs on the oldest Node version in "engines".
15. Key Points
- Node chooses the module system per file: .mjs/.cjs always win, and .js follows the nearest "type".
- ES module specifiers need full relative paths with extensions. Make them explicit while still on CommonJS.
- Replace __dirname/__filename with import.meta.dirname/filename, and JSON requires with import attributes.
- On supported Node versions, require() can load synchronous ES modules, which makes leaf-first migration and ESM-only packages practical.
- Keep top-level await out of anything CommonJS code might require().
- "exports" controls a package's public surface. Dual builds risk duplicate state.
- Update TypeScript ("module": "nodenext") and the test runner together with the code.
16. Final Summary
Converting a Node.js project to ES modules is mostly mechanical once you know the rules. Set "type", use explicit extensions, and replace the CommonJS-only globals with their import.meta equivalents. JSON needs an import attribute, and dynamic loading goes through import(). The hard parts are at the boundaries: tool configs that break when "type" flips, consumers that depend on your old export shape, test mocking, and packages published in two formats.
Current Node releases make the transition easier than it used to be. Since 20.19 and 22.12, CommonJS can require() ES modules that have no top-level await, so you can migrate leaf-first, and ESM-only packages no longer lock out CommonJS users. Work through the order in section 3 and run the checklist. The Node.js ESM documentation, modules documentation and packages documentation are the primary references.