# CommonJS to ES Modules Migration Playbook for Node.js

Source: https://devdocs10.com/articles/javascript/js-commonjs-to-esm  
By Murat Bo · Published 2026-10-17 · DevDocs10 (https://devdocs10.com)

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](/articles/javascript/js-modules), CommonJS `require`, the basics of [ESM and CommonJS interop](/articles/javascript/js-esm-cjs-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

1. **Upgrade Node** locally and in CI, and set `"engines": { "node": ">=22.12" }`.
2. **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`.
3. **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.
4. **Protect files that must stay CommonJS:** rename them to `.cjs`, or convert them if the tool accepts ESM configs.
5. **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.
6. **Convert the syntax** (sections 4–8).
7. **Update tooling:** TypeScript, the test runner, linters, bundlers (section 11).
8. **For packages:** define `"exports"` and decide between ESM-only and dual publishing (section 10).
9. **Verify** with the checklist in section 14.

## 4. Before and After: A Small CLI Package

**Before (CommonJS).** `package.json`:

```css
{
  "name": "inventory-cli",
  "version": "1.0.0",
  "main": "src/index.js",
  "bin": { "inventory": "src/index.js" }
}
```

```javascript
// 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 };
```

```javascript
// src/format.js
function formatPrice(cents, currency = "USD") {
  return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(cents / 100);
}

module.exports = formatPrice;
```

```javascript
// 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`:

```css
{
  "name": "inventory-cli",
  "version": "2.0.0",
  "type": "module",
  "exports": "./src/index.js",
  "bin": { "inventory": "src/cli.js" },
  "engines": { "node": ">=22.12" }
}
```

```javascript
// 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 };
}
```

```javascript
// src/format.js
export function formatPrice(cents, currency = "USD") {
  return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(cents / 100);
}
```

```javascript
// src/drivers/memory.js
export const name = "memory";

export function list() {
  return [{ sku: "A1", cents: 1999 }];
}
```

```javascript
// 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 };
```

```javascript
#!/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

```javascript
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

```javascript
// 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:

```javascript
// 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:

```javascript
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:

```javascript
// 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;
```

```javascript
// 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`:

```javascript
// 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); }
```

```javascript
// app.cjs
const { default: Point, distance } = require("./point.mjs");
console.log(distance(new Point(0, 0), new Point(3, 4)));   // 5
```

The 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"`:

```javascript
// 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:

```css
{
  "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:

```javascript
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 classes
```

Classes, 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](https://github.com/nodejs/package-examples).

## 11. Tooling: TypeScript, Jest and Vitest

### TypeScript

```css
{
  "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](https://jestjs.io/docs/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](https://nodejs.org/api/esm.html), [modules documentation](https://nodejs.org/api/modules.html) and [packages documentation](https://nodejs.org/api/packages.html) are the primary references.
