The Great Migration: CJS to ESM in the npm 2026
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-modulefor forcing ESM - Bundlers handle it — Vite, esbuild, Rollup resolve CJS/ESM automatically in most cases
Why ESM Matters Beyond Syntax
The surface difference between CommonJS and ES Modules — require() versus import — understates the fundamental architectural difference between the two module systems. ESM was designed to solve problems that CJS's synchronous, runtime-evaluated model cannot solve, and understanding those problems explains why the ecosystem migration is happening at all rather than staying comfortable with require() indefinitely.
The most consequential property of ESM is static analysis. In CommonJS, require() is a function call that can appear anywhere in code, can accept dynamic string expressions, and is resolved at runtime. This means a bundler examining a CJS module cannot know at build time which exports will actually be used. Tree shaking — the process of removing unused exports from the final bundle — is not possible with CommonJS in the general case. ESM's import statements must appear at the top level of a module and must use static string literals. This gives bundlers complete knowledge of the dependency graph at build time. The result is that switching from CJS to ESM dependencies can meaningfully reduce bundle sizes in applications that use tree shaking. For frontend code, this matters directly — smaller bundles mean faster load times. For server-side code, it matters less, but the discipline of shipping only what's used is still valuable.
Top-level await is the second major ESM advantage that directly affects how modern server code is written. In CommonJS, await can only appear inside an async function. This forces initialization patterns where async setup work happens in a function that is called and awaited manually, which then calls further initialization functions. The module itself cannot express "I need to wait for this async operation before I'm ready to be used." ES modules support await at the top level of the module, which means initialization can happen declaratively. Database connection setup, config file loading, certificate loading — these can all be top-level awaited in ESM, making the initialization flow linear and easier to reason about.
// ESM top-level await — clean initialization
import { createConnection } from './db.js';
export const db = await createConnection(process.env.DATABASE_URL);
// Importers of this module wait for db to be ready automatically
Browser-native module loading is the third dimension. ESM is the JavaScript standard module system that browsers implement natively. <script type="module"> loads ES modules directly. CJS was never designed for browsers and requires a bundler to simulate it. While most production web code is bundled regardless, the alignment between the module system that Node.js uses and the module system browsers support reduces the conceptual overhead of working across environments and is the foundation for tools like Vite's dev mode, which serves unbundled ESM directly to the browser during development for instant hot module replacement.
Deferred module evaluation is subtler but matters in specific contexts. CJS modules are evaluated immediately and synchronously when first required. ESM modules can use dynamic import() for deferred loading, splitting initialization cost across the application's lifecycle. For CLI tools and applications with multiple code paths, this means faster startup times — code paths that aren't needed for a particular invocation are never loaded. This is why modern CLI tools built on ESM start faster than their CJS predecessors even when the code logic is identical.
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 to 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 asymmetry in the table above is the entire reason the migration took five years rather than one. Node.js 12 shipped stable ESM support in 2019, but the ecosystem couldn't simply flip a switch because of the one broken cell in that table: CJS cannot require() ESM packages. This sounds like a technical limitation that could be fixed, and partially it has been — Node.js 22 ships an experimental flag --experimental-require-module that allows requiring ESM from CJS files. But for most of the migration window, this capability did not exist.
The cascade effect works like this: imagine a popular utility package — call it Package A — that 50,000 other packages depend on. Package A wants to adopt ESM. If Package A publishes ESM-only, every one of those 50,000 downstream packages must also migrate to ESM or stay pinned to the last CJS version of Package A. If any of those 50,000 packages is itself depended on by other packages that haven't migrated, the requirement propagates outward. For packages deep in the dependency graph — the ones everything else depends on, like utility libraries for string handling, color output, HTTP requests — going ESM-only created cascading version pins across the ecosystem.
The dual-package pattern emerged as the pragmatic solution. Rather than forcing all consumers to migrate at once, library authors could publish both CJS and ESM builds using the package.json exports field, allowing consumers to continue using the CJS version while migrating to ESM on their own timeline. This reduced the migration pressure but also extended the transition period — if packages always ship both, there's less urgency to migrate.
The Sindresorhus Decision and Its Aftermath
When Sindre Sorhus, the author of roughly 1,000 npm packages, announced in 2021 that all of his packages going forward would be ESM-only, it was the single most disruptive individual decision in the CJS-to-ESM migration. Sorhus's packages are not obscure utilities — they include chalk (terminal color output), got (HTTP client), execa (child process execution), p-queue, globby, del, and dozens of other packages that appear in the dependency trees of millions of projects.
Sorhus's stated reasoning was direct: ESM is the future of JavaScript modules, maintaining both CJS and ESM builds doubles the maintenance burden, and the JavaScript community would migrate faster if there was more pressure to do so. The decision was deliberate, documented publicly, and unambiguous. Chalk v5, got v12, execa v7 — ESM only, no CJS builds.
The community reaction was intense and divided. One camp agreed with the principle: the migration had to start somewhere, and gradual migration was enabling procrastination. The other camp focused on the practical cost: developers maintaining CJS codebases found that upgrading to the latest version of chalk broke their applications with ERR_REQUIRE_ESM. Projects pinned to chalk v4, got v11, execa v6 for years. The last CJS version of these packages became a ceiling on how far those projects could upgrade their dependency trees.
What actually happened was more nuanced than either camp predicted. Some packages reversed course after community feedback. Execa v8 added CJS back. Got v14 became a dual package. Nanoid v5 added CJS back after v4 went ESM-only. P-queue v8 restored CJS. The pattern: packages that went ESM-only, received significant real-world friction reports from users, and had maintainers willing to do the dual-build work, eventually reverted to dual packages.
The packages that remained ESM-only are the ones where maintainer conviction was strongest or where the user base was already predominantly ESM. Chalk v5 remains ESM-only — kleur and picocolors became the CJS alternatives, and many CJS projects simply switched libraries rather than migrating module formats.
What this episode tells us about ecosystem migrations: individual maintainers can accelerate migration by creating pressure, but they cannot control downstream migration speed. The ESM-only decisions of 2021-2022 did accelerate ESM adoption — more developers learned what ESM was, more projects updated their tooling, more new packages were started ESM-first. The cost was temporary ecosystem fragmentation and a meaningful amount of developer frustration. Whether it was the right tradeoff is a question on which reasonable people still disagree. What's clear is that the migration is substantially further along in 2026 than it would have been without the pressure.
See the ESM migration guide for a practical walkthrough of migrating an existing CJS project to ESM.
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');
Bundlers and the CJS/ESM Question
The most important practical insight about the CJS-to-ESM migration is that if you are building an application with a bundler, you probably don't need to care about it in most cases. Vite, webpack, esbuild, and Rollup all handle CJS/ESM interoperability as part of their normal operation, transparently resolving the module format differences so application developers can focus on writing code.
Vite's module handling is the clearest example. During development, Vite serves your source files as ESM to the browser directly — it doesn't bundle. When a module in your source file imports a CJS package, Vite's dependency pre-bundling step (which runs esbuild) converts that CJS package to ESM so it can be served as a native module. This happens automatically, without configuration, and without the developer needing to understand the underlying module format of every dependency. The ERR_REQUIRE_ESM errors that plague Node.js-native code simply don't appear in Vite applications because Vite normalizes everything to ESM before serving it.
Webpack 5 handles both module systems and can consume both CJS and ESM packages in the same bundle. It performs tree shaking on ESM packages but not CJS packages, which is why migrating your own application code to ESM export syntax can reduce bundle sizes even when your dependencies are a mix of formats. esbuild is similarly format-agnostic at the bundler level.
Rollup is the clearest case for CJS/ESM interoperability being invisible to application developers: Rollup processes ESM natively and uses @rollup/plugin-commonjs to convert CJS dependencies to ESM for processing. This plugin is mature and handles the vast majority of CJS patterns correctly. The Rollup vs webpack comparison covers which bundler is appropriate for different use cases.
The CJS/ESM question becomes genuinely your problem in specific scenarios. First, if you are writing server-side Node.js code without a bundler — scripts, CLI tools, raw Node.js servers — you are working with the native module system and must understand the interoperability rules. Second, if you are a library author publishing an npm package, you must decide what module formats to ship and configure your exports field correctly. Third, if you are using tsx or ts-node to run TypeScript files directly (without a bundler), you need your source files and their imports to be format-consistent.
Application developers using Next.js, Vite, or similar frameworks: the bundler handles it, and you rarely need to think about module formats. CLI tool authors and library authors: module format is a first-class concern that affects every package you depend on.
Node.js Module System Deep Dive
Node.js determines which module system to use for a given file through a set of rules that are sometimes confusing and occasionally surprising. Understanding the precise rules prevents the class of errors where a file works in one context but fails when called from another.
The file extension is the first signal Node.js reads. A .mjs file is always treated as ESM. A .cjs file is always treated as CommonJS. A .js file is ambiguous — its treatment depends on the nearest package.json with a "type" field.
The "type" field in package.json sets the default for .js files in that package. "type": "module" means .js files are ESM. No "type" field (or "type": "commonjs") means .js files are CJS. This lookup walks up the directory tree, so a package.json in a parent directory sets the default for subdirectories that don't have their own package.json.
The exports field in package.json is the modern way packages declare their entry points and conditions. Multiple conditions can be defined for the same export path, and Node.js selects the appropriate entry based on context:
{
"exports": {
".": {
"import": "./dist/index.js", // Used when imported with ESM import
"require": "./dist/index.cjs", // Used when required with CJS require()
"default": "./dist/index.js", // Fallback
"types": "./dist/index.d.ts" // TypeScript declarations
}
}
}
The import condition is activated when the package is loaded via an ESM import statement. The require condition is activated when loaded via require(). The browser condition is honored by bundlers targeting browser environments. Understanding this lets you read any package's package.json to quickly determine whether it supports your module format.
The dual package hazard is a subtle issue for dual-package (CJS + ESM) libraries. When the same package is loaded as both CJS and ESM in the same application — which can happen if different parts of the dependency tree use different module formats — two separate instances of the package are created. If the package maintains any singleton state (a logger instance, a connection pool, a registry), there will be two separate instances, and the application may behave incorrectly. Packages that manage shared state typically address this by using the require condition to always load the same CJS singleton, or by documenting that the package should only be used as ESM.
Node.js 22's --experimental-require-module flag partially addresses the fundamental CJS-cannot-require-ESM limitation. With this flag, require() of a synchronous ESM module (a module with no top-level await) works. Modules with top-level await still cannot be required. This flag is experimental in Node.js 22 and may be stabilized in Node.js 24. It doesn't solve the dual-package hazard and doesn't make top-level await work in required ESM modules, but it reduces the surface area of migration blockers.
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
The State of ESM Adoption in 2026: Data
The migration from CJS to ESM is measurable, and the numbers in 2026 tell a story of genuine progress mixed with a long tail of legacy code that will take years to clear.
Among the top 1000 packages by weekly downloads, approximately 42% ship ESM-only, around 38% ship dual packages (both CJS and ESM via the exports field), and the remaining 20% are still CJS-only. That last number — 20% of the most popular packages still CJS-only — is the stubborn residual. It includes packages that are functionally complete and unmaintained (no new releases, but still heavily used), packages in the infrastructure and build tool space where ESM migration is complicated by circular dependency patterns, and a small number of popular packages whose maintainers have explicitly decided to stay on CJS.
The trend line from 2023 to 2026 shows consistent movement: in early 2023, ESM-only packages among the top 1000 were around 18%, with dual packages at roughly 25%. The shift to the current 42%/38%/20% split represents substantial migration activity. The dual-package category grew fastest — packages that would have remained CJS-only got pressure to support ESM while keeping CJS for backward compatibility.
New package creation tells a cleaner story. Among packages created in 2025-2026, ESM-first (either ESM-only or ESM with CJS compatibility via exports) accounts for roughly 80% of new packages. Developers starting new projects today default to ESM. The remaining 20% of new CJS packages are typically tooling, build infrastructure, or code that runs in contexts where ESM support is limited.
Download-weighted metrics differ from count metrics. The very highest-download packages — Node.js core adjacent utilities, build tool dependencies, testing infrastructure — tend to ship dual packages because they serve an enormous user base with heterogeneous module requirements. The ESM-only packages cluster in the utility and application-level library space. If you weight by download volume rather than package count, the effective CJS compatibility coverage is higher than the raw package-count numbers suggest.
For practical migration decisions: the top-1000-package coverage means you are unlikely to encounter an unresolvable ESM-only dependency in 2026. The packages that caused the most friction (chalk, got, execa) have either added CJS back or have well-established CJS alternatives. The migration blockers of 2021-2022 are largely resolved. See the how to choose npm pnpm yarn 2026 guide for package manager considerations related to ESM module resolution.
The 2026 State of Popular Packages
| Package | Current | CJS Support | Notes |
|---|---|---|---|
| chalk | v5 | ❌ (v4 = last CJS) | Use kleur/picocolors for CJS |
| got | v14 | ✅ Dual | v14 restored CJS after community pressure |
| node-fetch | v3 | ❌ | Use native fetch (Node.js 18+) |
| execa | v9 | ✅ Dual | Restored CJS in v8 after feedback |
| nanoid | v5 | ✅ Dual | v5 added CJS back after v4 ESM-only |
| p-queue | v8 | ✅ Dual | Restored CJS |
| glob | v10 | ✅ Dual | Core Node.js-adjacent packages tend dual |
| p-limit | v6 | ✅ Dual | Restored |
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.
See also: The ESM vs CJS Adoption Gap Across npm, ESM Migration Guide: CommonJS to ESM 2026, and The State of TypeScript Tooling in 2026 for how module format intersects with TypeScript configuration.
See the live comparison
View rollup vs. webpack on PkgPulse →