Skip to main content

ESM Migration Guide: CommonJS to ESM 2026

·PkgPulse Team
0

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:

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

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

Then configure package.json exports:

{
  "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.


Syntax Changes: CJS to ESM

Imports

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

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

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

Exports

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

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

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

// 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):

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

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

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

# 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.exportsexport
  • __dirname/__filenameimport.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:

# 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:

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

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

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

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

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

// 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.


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

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.