<!-- PkgPulse AI-readable guide source -->
<!-- Canonical: https://www.pkgpulse.com/guides/esm-migration-guide-commonjs-to-esm-2026 -->
<!-- Raw Markdown: https://www.pkgpulse.com/guides/esm-migration-guide-commonjs-to-esm-2026/raw.md -->
<!-- Source path: content/guides/esm-migration-guide-commonjs-to-esm-2026.mdx -->

---
og_image: "/images/guides/esm-migration-guide-commonjs-to-esm-2026.webp"
title: "ESM Migration Guide: CommonJS to ESM 2026"
description: "Complete guide to migrating Node.js packages from CommonJS to ESM in 2026: type:module, import.meta.url, dynamic import(), Node 22 require(ESM), and tools like."
date: "2026-03-29"
author: "PkgPulse Team"
tags: ["esm", "commonjs", "node", "modules", "migration", "javascript", "2026"]
---

## TL;DR

Node.js 22 removed the last major blocker for ESM adoption: you can now synchronously `require()` ESM modules from CJS code. The ecosystem has no remaining technical reason to stay on CommonJS for new packages. Migration means setting `"type": "module"` in `package.json`, replacing `require()` with `import`, replacing `__dirname` and `__filename` with `import.meta.url` equivalents, and handling a few edge cases around dynamic imports and JSON loading. The `cjstoesm` tool automates much of the conversion for existing codebases.

## Key Takeaways

- Node.js 22 supports synchronous `require()` of ESM packages — the CJS-can't-import-ESM problem is resolved
- Set `"type": "module"` in `package.json` to treat all `.js` files as ESM
- Or use `.mjs` extension per-file without changing `package.json`
- `__dirname` and `__filename` don't exist in ESM — replace with `import.meta.dirname` (Node 21.2+) or `fileURLToPath(import.meta.url)`
- Dynamic `import()` is available in both CJS and ESM — useful for conditional loading
- ESM enables tree shaking in bundlers; CJS does not
- `cjstoesm` and `tsc` with `"module": "NodeNext"` automate most of the conversion

---

## Why ESM Matters in 2026

CommonJS (`require()`) was the de facto module system for Node.js from its earliest days. ECMAScript Modules (ESM, `import`/`export`) became the JavaScript language standard in ES2015 and were added to Node.js in v12 (2019). Seven years later, the transition is effectively complete.

The reasons to migrate:

**Tree shaking.** Bundlers like Webpack, Rollup, and esbuild can perform dead code elimination only with ESM's static `import` statements. CJS's dynamic `require()` calls can't be statically analyzed, so the entire module is included. A package that exports 50 functions but a consumer only uses 2 costs the full bundle size in CJS; in ESM, unused exports are eliminated.

**Top-level await.** ESM supports `await` at the module's top level without an async wrapper function. This is useful for initialization code, database connections, and configuration loading.

**Standard compatibility.** Deno, Cloudflare Workers, browser native modules, and the JSR registry all speak ESM natively. CJS is a Node.js-specific convention that the broader JavaScript ecosystem never adopted.

**Node.js 22 resolved the last blocker.** The biggest argument against going ESM-only was that CJS consumers couldn't `require()` ESM packages (they had to use dynamic `import()`, which is async). Node.js 22 added `--experimental-require-module` as stable behavior — CJS code can now synchronously `require()` ESM modules. The compatibility wall is gone.

---

## The Three Migration Approaches

### Option 1: `"type": "module"` in package.json

Setting `"type": "module"` tells Node.js to treat all `.js` files in the package as ESM:

```json
{
  "name": "my-package",
  "type": "module"
}
```

After setting this:
- `.js` files are ESM by default
- `.cjs` files are still treated as CommonJS (explicit CJS extension)
- `import`/`export` syntax works in `.js` files
- `require()` stops working in `.js` files (use `import` instead)

This is the cleanest approach for new packages or packages that are ready for a full migration.

### Option 2: Per-file `.mjs` extension

Without changing `package.json`, you can use ESM in specific files by using the `.mjs` extension:

```
index.mjs        ← ESM
helpers.mjs      ← ESM
legacy.js        ← still CJS
```

This is useful during incremental migration or when you need to maintain CJS compatibility for some files.

### Option 3: Dual CJS/ESM publishing

For packages that need to support both CJS and ESM consumers, publish both formats using a bundler like tsup or unbuild:

```bash
# tsup generates both formats from TypeScript source
npx tsup src/index.ts --format cjs,esm --dts
```

Then configure `package.json` exports:

```json
{
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    }
  }
}
```

For the full dual-publish setup and npm publishing workflow, see [Publishing an npm Package: Complete Guide 2026](/guides/publishing-npm-package-complete-guide-2026).

---

## Syntax Changes: CJS to ESM

### Imports

```javascript
// CJS
const express = require('express');
const { readFile } = require('fs/promises');
const path = require('path');

// ESM
import express from 'express';
import { readFile } from 'fs/promises';
import path from 'path';
```

For packages that don't have a default export, use named imports:

```javascript
// CJS
const { EventEmitter } = require('events');

// ESM
import { EventEmitter } from 'events';
```

### Exports

```javascript
// CJS
module.exports = { foo, bar };
module.exports.default = MyClass;
exports.helper = function() {};

// ESM
export { foo, bar };
export default MyClass;
export function helper() {}
```

### Dynamic Imports

Dynamic `import()` is available in both CJS and ESM and is useful for conditional loading:

```javascript
// Works in both CJS and ESM
const module = await import('./optional-feature.js');
if (process.env.FEATURE_FLAG) {
  const { feature } = await import('./feature.js');
  feature.init();
}
```

In CJS files that can't use top-level await, wrap in an async function:

```javascript
// CJS file needing to import an ESM module
async function loadEsmModule() {
  const { doThing } = await import('./esm-module.mjs');
  return doThing;
}
```

---

## Replacing __dirname and __filename

`__dirname` (directory of current file) and `__filename` (path to current file) are CJS globals that don't exist in ESM. In ESM, use `import.meta.url`:

```javascript
// CJS
const __dirname = require('path').dirname(__filename);
const filePath = path.join(__dirname, 'data', 'config.json');

// ESM — Node.js 21.2+ (simplest)
const filePath = path.join(import.meta.dirname, 'data', 'config.json');

// ESM — Node.js 20 and earlier (wider compatibility)
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const filePath = path.join(__dirname, 'data', 'config.json');
```

`import.meta.dirname` was added in Node.js 21.2 as a convenience property. For packages targeting Node.js 20 LTS, the `fileURLToPath` approach is still needed.

---

## Importing JSON

CJS could `require()` JSON files directly. ESM requires an import assertion (or import attribute in the newer syntax):

```javascript
// CJS
const config = require('./config.json');

// ESM (Node.js 17.1+ with import assertions)
import config from './config.json' assert { type: 'json' };

// ESM (Node.js 21+ with import attributes — newer syntax)
import config from './config.json' with { type: 'json' };

// ESM alternative (works in all Node.js versions)
import { readFile } from 'fs/promises';
const config = JSON.parse(
  await readFile(new URL('./config.json', import.meta.url), 'utf8')
);
```

The import assertion/attribute syntax is the most ergonomic when available. The `readFile` approach is the most portable.

---

## TypeScript Configuration for ESM

TypeScript's `moduleResolution` and `module` settings need to match the runtime:

```json
// tsconfig.json for ESM Node.js package
{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "target": "ES2022",
    "outDir": "./dist"
  }
}
```

With `NodeNext`, TypeScript enforces ESM-style imports including explicit file extensions:

```typescript
// Required with NodeNext — explicit .js extension even in TypeScript files
import { helper } from './helper.js'; // .js, not .ts

// TypeScript resolves this to helper.ts during compilation
// Node.js resolves this to helper.js at runtime
```

This explicit extension requirement catches people off guard. TypeScript's `NodeNext` module resolution maps `helper.js` imports to `helper.ts` files during compilation, then the output uses real `.js` files at runtime.

---

## Automated Migration with cjstoesm

For existing CJS codebases, `cjstoesm` automates much of the mechanical conversion:

```bash
# Install
npm install -g cjstoesm

# Convert a single file
cjstoesm src/index.js

# Convert an entire directory
cjstoesm src/ dist/

# Dry run to preview changes
cjstoesm --dry src/
```

`cjstoesm` handles:
- `require()` → `import`
- `module.exports` → `export`
- `__dirname`/`__filename` → `import.meta.url` equivalents
- Dynamic `require()` → dynamic `import()`

What `cjstoesm` doesn't handle automatically:
- Conditional requires that depend on runtime logic
- Circular dependencies (ESM handles these differently from CJS)
- JSON imports (need manual assertion syntax)
- `require.resolve()` calls (use `import.meta.resolve()` in Node.js 20.6+)

After running `cjstoesm`, expect to manually fix 5-20% of cases depending on codebase complexity.

---

## Common Migration Pitfalls

### Circular Dependencies

CJS handles circular dependencies lazily (incomplete module objects at the time of first require). ESM handles them with live bindings but will throw on circular dependencies with certain patterns. Before migrating, run:

```bash
# Find circular dependencies
npx madge --circular src/
```

Resolve circular dependencies before migration — the ESM behavior change can cause hard-to-debug runtime errors.

### Missing File Extensions

Node.js ESM resolver requires explicit file extensions in imports. TypeScript with `NodeNext` enforces this during compilation, but JavaScript migrations often have imports without extensions:

```javascript
// This works in CJS (Node.js resolves .js automatically)
import { foo } from './foo'; // ← missing extension — fails in ESM Node.js

// ESM requires explicit extension
import { foo } from './foo.js';
```

Run a search for extensionless imports before declaring migration complete.

### Jest and Test Runner Compatibility

Some test runners have historically had friction with ESM. Jest requires additional configuration:

```json
// package.json for ESM Jest
{
  "jest": {
    "extensionsToTreatAsEsm": [".ts"],
    "transform": {}
  }
}
```

Vitest is ESM-native and handles ESM without configuration, making it the path-of-least-resistance test runner for ESM packages. For new packages, Vitest removes this migration complexity entirely.

### require() of CJS Modules from ESM

ESM code can `import` CJS modules. The default export receives the entire `module.exports` object:

```javascript
// Importing a CJS package from ESM
import lodash from 'lodash'; // default import = module.exports
const { chunk, flatten } = lodash;

// Named imports from CJS don't work reliably
import { chunk } from 'lodash'; // ← may fail depending on package structure
```

For CJS packages that don't support named imports from ESM, use the full default import and destructure.

---

## Node.js 22: require(ESM) Changes Everything

The historical barrier to ESM adoption was the asymmetry: ESM code could `import` CJS packages, but CJS code couldn't `require()` ESM packages (they had to use async `await import()`). This created a "dual publish" requirement for libraries wanting to support both consumer types.

Node.js 22 made synchronous `require()` of ESM modules work in most cases:

```javascript
// Now works in CJS (Node.js 22+)
const { tool } = require('esm-only-package');
```

The feature requires that ESM modules don't use top-level await (since `require()` is synchronous and can't await). Packages with top-level await still require dynamic `import()` from CJS callers.

This change significantly reduces the pressure to dual-publish. New packages can publish ESM-only and CJS consumers on Node.js 22+ can use them without code changes. The dual-publish requirement remains for Node.js 18 LTS and Node.js 20 LTS compatibility.

---

## ESM and Bundle Size

One of the concrete benefits of ESM migration is tree shaking in bundlers. This matters most for packages used in browser environments where bundle size directly affects performance.

Named ESM exports enable granular tree shaking:

```javascript
// ESM — bundler can eliminate unused exports
export function needed() { /* ... */ }
export function alsoNeeded() { /* ... */ }
export function notUsed() { /* large implementation */ }

// Consumer imports only what they need
import { needed } from 'my-package';
// 'notUsed' is eliminated from the bundle
```

CJS default exports bundle everything:

```javascript
// CJS — bundler can't tree-shake this
module.exports = {
  needed: function() {},
  alsoNeeded: function() {},
  notUsed: function() { /* large implementation, always included */ }
};
```

For package authors, shipping ESM output enables consumers to get smaller bundles. For the detailed guide on package size optimization including the `sideEffects` field and tree shaking configuration, see [Package Size Optimization and Tree Shaking 2026](/guides/package-size-optimization-tree-shaking-2026).

---

## Migration Checklist

Before declaring ESM migration complete:

- [ ] `"type": "module"` set in `package.json` (or all files converted to `.mjs`)
- [ ] All `require()` calls replaced with `import` statements
- [ ] All `module.exports` replaced with `export` statements
- [ ] `__dirname`/`__filename` replaced with `import.meta.url` equivalents
- [ ] JSON imports use assertion/attribute syntax or `readFile`
- [ ] TypeScript configured with `"module": "NodeNext"`
- [ ] All import paths include explicit file extensions
- [ ] Circular dependencies resolved
- [ ] Tests pass under ESM (Vitest or Jest with ESM config)
- [ ] `package.json` `exports` field updated for the distribution format
- [ ] Consumers on supported Node.js versions (18 LTS, 20 LTS, 22 LTS)

---

## Methodology

This article draws on:

- Node.js 22 release notes and ESM documentation
- Node.js ESM module documentation (nodejs.org/api/esm)
- TypeScript documentation on `NodeNext` module resolution
- cjstoesm documentation and source
- Sindre Sorhus's "Pure ESM package" GitHub gist
- Vitest documentation on ESM support
- Jest ESM documentation and configuration guide
- TC39 import attributes proposal documentation

*See also: [AVA vs Jest](/compare/ava-vs-jest)*
