Skip to main content

The Great Migration: CJS to ESM in the npm Ecosystem

·PkgPulse Team

TL;DR

ESM is the standard. CJS is the compatibility layer. In 2026, ~65% of new npm packages ship ESM-first or ESM-only. Major packages that went ESM-only (chalk v5, node-fetch v3, got v12, p-queue v7, nanoid v4, sindresorhus's entire portfolio) forced millions of developers to understand the difference. Node.js 22 supports both, but the friction of mixing CJS and ESM is still the #1 source of confusing "ERR_REQUIRE_ESM" errors. Here's the definitive state of the migration in 2026.

Key Takeaways

  • 65% of new npm packages — ship ESM first or ESM-only in 2026
  • Sindresorhus effect — one developer's ESM-only decision affected ~1000 packages
  • Dual packages — ~40% of popular packages ship both CJS + ESM via package.json exports
  • Node.js 22 — full ESM support, top-level await, --experimental-require-module for forcing ESM
  • Bundlers handle it — Vite, esbuild, Rollup resolve CJS/ESM automatically in most cases

CJS vs ESM: The Fundamental Difference

// CommonJS (CJS) — the original Node.js module system (2009)
// ✅ Synchronous, works in all Node.js versions
// ❌ No static analysis, no tree-shaking
const express = require('express');
const { something } = require('./utils');
module.exports = { myFunc };

// ES Modules (ESM) — the JavaScript standard (ES2015, Node.js 12+)
// ✅ Static, tree-shakeable, works in browsers
// ✅ Top-level await
// ❌ Must use .mjs extension OR "type": "module" in package.json
import express from 'express';
import { something } from './utils.js';  // .js extension required!
export { myFunc };

Why This Migration Took So Long

The CJS → ESM migration is still ongoing in 2026 because of a fundamental asymmetry:

CJS can require() CJS packages:           ✅
ESM can import CJS packages:              ✅ (Node.js wraps CJS)
CJS can require() ESM packages:           ❌ (ERR_REQUIRE_ESM!)
ESM can import ESM packages:              ✅

The problem: if package A (CJS) depends on package B (ESM-only),
package A can't upgrade B without becoming ESM itself.

This created a "migration blocker" cascade:
chalk v5 went ESM-only → many tools couldn't upgrade chalk → stayed on v4

The Packages That Forced the Migration

Sindresorhus's Portfolio (~1000 packages)

# These popular packages went ESM-only:
chalk@5       → ESM only (chalk@4 = last CJS version)
got@12        → ESM only (got@11 = last CJS version)
p-queue@7     → ESM only
execa@7       → ESM only (execa@8 now CJS+ESM again!)
is-stream@3   → ESM only
del@7         → ESM only
tempy@3       → ESM only
globby@13     → ESM only

# Package authors' message: "Use CJS version 4/5/6 until you migrate to ESM"

node-fetch v3

// node-fetch v2 (CJS — most projects still use this or native fetch)
const fetch = require('node-fetch');  // Works in CJS

// node-fetch v3 (ESM only)
import fetch from 'node-fetch';      // Requires ESM

// 2026 recommendation: Use native fetch (Node.js 18+)
// No import needed — fetch is global in Node.js 18+
const response = await fetch('https://api.example.com');

The Package.json Exports Field (Dual Packages)

The industry settled on a standard: dual-package pattern via exports:

// package.json — dual CJS + ESM package (the 2026 standard)
{
  "name": "my-package",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.js",    // ESM entry
      "require": "./dist/index.cjs",  // CJS entry
      "types": "./dist/index.d.ts"    // TypeScript declarations
    },
    "./utils": {
      "import": "./dist/utils.js",
      "require": "./dist/utils.cjs"
    }
  },
  "main": "./dist/index.cjs",   // Fallback for old Node.js
  "module": "./dist/index.js",  // Bundler hint (Rollup, webpack)
  "types": "./dist/index.d.ts"
}
# tsup — builds both CJS and ESM from one command
npx tsup src/index.ts --format cjs,esm --dts --clean

# Output:
# dist/index.js     — ESM
# dist/index.cjs    — CommonJS
# dist/index.d.ts   — TypeScript declarations
# dist/index.d.cts  — CTS declarations (for CJS consumers)

The ERR_REQUIRE_ESM Error (And Fixes)

# The most common error when mixing CJS and ESM:
Error [ERR_REQUIRE_ESM]: require() of ES Module /node_modules/chalk/source/index.js
from /my-app/src/logger.js not supported.
Instead change the require of chalk in /my-app/src/logger.js to a dynamic import()
which is available in all CommonJS modules.
// Fix 1: Stay on CJS version
// package.json: "chalk": "^4.1.2"  // NOT v5

// Fix 2: Convert your file to ESM
// Rename logger.js → logger.mjs OR add "type": "module" to package.json
import chalk from 'chalk';

// Fix 3: Dynamic import (in CJS files)
// Works but ugly — only when needed
const chalk = await import('chalk');
// OR:
const { default: chalk } = await import('chalk');
chalk.green('Hello');

// Fix 4: Use an alternative that ships CJS
// chalk v4 → kleur, picocolors (CJS + ESM, tiny)
import { green } from 'kleur/colors';

Node.js Project Configuration for ESM

// package.json — opting into ESM
{
  "type": "module",  // .js files are now ESM
  "scripts": {
    "dev": "tsx watch src/index.ts",   // tsx handles ESM automatically
    "build": "tsc"
  }
}
// tsconfig.json — ESM-compatible TypeScript config
{
  "compilerOptions": {
    "module": "NodeNext",           // NodeNext = ESM-aware module resolution
    "moduleResolution": "NodeNext",
    "target": "ES2022"
  }
}
// Critical: In ESM TypeScript, you must include .js extensions
// (yes, .js even when the source is .ts — TypeScript resolves to the compiled output)
import { calculateScore } from './health-score.js';   // ✅ .js required
import { db } from '../lib/db.js';                    // ✅ .js required
// import { db } from '../lib/db';                    // ❌ Fails in NodeNext mode

PackageCurrentCJS SupportNotes
chalkv5❌ (v4 = last CJS)Use kleur/picocolors for CJS
gotv14✅ Dualv14 restored CJS after community pressure
node-fetchv3Use native fetch (Node.js 18+)
execav9✅ DualRestored CJS in v8 after feedback
nanoidv5✅ Dualv5 added CJS back after v4 ESM-only
p-queuev8✅ DualRestored CJS
globv10✅ DualCore Node.js-adjacent packages tend dual
p-limitv6✅ DualRestored

The pattern: packages often went ESM-only, faced community pushback, then added CJS back as dual packages. The pure ESM-only holdouts are mostly opinionated smaller utilities.


Practical Advice for 2026

# Check if a package supports your module system
cat node_modules/chalk/package.json | grep -A 10 '"exports"'

# For new projects:
# ESM: "type": "module", .js files are ESM
# CJS: no "type" field (default), .js = CJS

# For CLI tools/scripts: use tsx (handles both transparently)
tsx my-script.ts

# For library publishing: ship both via tsup
npx tsup src/index.ts --format cjs,esm --dts

# For bundled apps (Next.js, Vite): don't worry about it
# Bundlers resolve CJS/ESM automatically

Compare module ecosystem package health on PkgPulse.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.