The ESM vs CJS Adoption Gap Across npm 2026
TL;DR
The ESM transition is mostly complete at the top of npm but still stalled in the long tail. 95% of new packages published in 2025 support ESM. But 40% of the top 100 packages still ship CommonJS (either CJS-only or dual CJS+ESM). The stickiness: Node.js CJS interop requirements, legacy code that can't use dynamic import(), and the infamous "ESM-only" migration pain (chalk v5, node-fetch v3, etc.). By 2027, the transition will be essentially complete for actively maintained packages.
Key Takeaways
- New packages (2024-2026): 95% ship ESM — it's the default now
- Existing top 100: ~40% still CJS or dual — migration takes time
- ESM-only packages cause pain — chalk v5, node-fetch v3 broke CJS projects
- Dual packages (CJS + ESM) are the pragmatic middle ground
type: "module"in package.json — the signal that a package is ESM-first
The State of ESM vs CJS (2026)
ESM adoption across npm:
New packages (published 2024-2026):
→ ESM-only: 55%
→ Dual CJS+ESM: 35%
→ CJS-only: 10%
Top 100 packages by downloads:
→ Dual CJS+ESM: 50%
→ ESM-only: 25%
→ CJS-only: 25%
Top 1000 packages:
→ Dual CJS+ESM: 42%
→ CJS-only: 38%
→ ESM-only: 20%
All packages:
→ CJS-only: 55% (majority still CJS)
→ Dual CJS+ESM: 30%
→ ESM-only: 15%
Trend: ESM-only is growing, CJS-only is shrinking
Timeline: Full ESM dominance expected by 2028
How to Check What a Package Ships
# Check package type field:
npm view package-name --json | jq '.type, .exports, .main, .module'
# type: "module" → the package is ESM-first
# type: undefined or "commonjs" → CJS or depends on file extension
# Exports field tells the full story:
npm view vite --json | jq '.exports'
# Should show something like:
# {
# ".": {
# "types": "./dist/node/index.d.ts",
# "require": "./dist/node/index.cjs", ← CJS build
# "import": "./dist/node/index.js" ← ESM build
# }
# }
# ESM-only package (no "require" field):
# {
# ".": {
# "types": "./dist/index.d.ts",
# "import": "./dist/index.js" ← only ESM
# }
# }
# Check locally:
cat node_modules/package-name/package.json | jq '.type, .exports'
The ESM-Only Migration Pain
// The chalk v5 incident: ESM-only, broke everything
// Before chalk v5:
const chalk = require('chalk'); // CJS, worked everywhere
// chalk v5+:
import chalk from 'chalk'; // ESM only
// If your project is CJS: BREAKS
// Error you'd see:
// Error [ERR_REQUIRE_ESM]: require() of ES Module not supported.
// Change require() of xxx.js to a dynamic import() which is available
// in all CommonJS modules.
// Workaround in CJS:
const chalk = await import('chalk'); // Dynamic import in async context
// But this doesn't work at the top level in CJS without async:
// ❌ const chalk = await import('chalk'); // SyntaxError in CJS
// The real fix: migrate your project to ESM
// Or: pin to the last CJS version
// "chalk": "4" ← pin to v4 (last CJS version)
// Same pattern happened with:
// node-fetch v3: ESM-only
// got v12: ESM-only
// Various UnJS packages: ESM-first
CJS vs ESM: The Technical Difference
// CommonJS (CJS):
const express = require('express');
const { readFile } = require('fs');
module.exports = { myFunction };
// ESM:
import express from 'express';
import { readFile } from 'fs';
export function myFunction() {}
// The fundamental difference:
// CJS: synchronous, evaluated at runtime
// ESM: static, parsed before execution, enables tree-shaking
// Why ESM enables tree-shaking:
// CJS: bundler can't know what you'll use at parse time
// ESM: static imports are analyzed → dead code eliminated
// Why CJS persists:
// 1. Huge existing codebase (15+ years of Node.js history)
// 2. require() is synchronous: simpler for many patterns
// 3. Dynamic require() patterns: require(`./plugins/${name}`)
// 4. Conditional requires in middleware/plugins
// 5. Some environments (old Node.js, some serverless) need CJS
// Node.js 22: interop improved
// You can require() an ESM module in Node.js 22+ (experimental flag)
// But not all environments have caught up yet
Migrating Your Package to ESM
// Step 1: Add "type": "module" to package.json
{
"name": "my-package",
"type": "module",
"main": "./dist/index.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}
// Step 2: Update source files
// Change all require() to import
import { readFile } from 'fs/promises';
// Change module.exports to export
export function myFunction() {}
export default class MyClass {}
// Step 3: Update __dirname and __filename (not available in ESM)
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Or use import.meta.url directly:
const configPath = new URL('./config.json', import.meta.url);
const config = JSON.parse(await readFile(configPath, 'utf8'));
// Step 4: Dynamic imports for CJS dependencies you can't replace
const { default: chalk } = await import('chalk');
The Dual Package Approach
// Most pragmatic: ship both CJS and ESM
// Consumers get what their environment needs
{
"name": "my-package",
"exports": {
".": {
"require": "./dist/index.cjs", // CJS: require() works
"import": "./dist/index.mjs", // ESM: import works
"types": "./dist/index.d.ts"
}
}
}
// Build tools that generate dual packages:
// tsup: most popular
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'], // Builds both
dts: true,
clean: true,
});
// pkgroll: rollup-based
// unbuild: nuxt ecosystem
// Both generate the dual output from a single source
ESM in Node.js: Current State
# Node.js ESM support by version:
# Node.js 12: ESM experimental (don't use)
# Node.js 14: ESM stable (basic support)
# Node.js 16: ESM well-supported
# Node.js 18 LTS: ESM recommended for new packages
# Node.js 20 LTS: ESM mature
# Node.js 22 LTS: require(ESM) supported behind flag (--experimental-require-module)
# Current recommendation:
# New packages: ESM-first with CJS compatibility via dual package
# New apps on Node 18+: can use ESM-only packages
# Apps on older Node: stick to dual packages, avoid ESM-only libraries
# Browser + Edge environments:
# All support ESM natively
# No CJS in browser — bundlers translate CJS to ESM for you
# The convergence:
# Node.js 22+: ESM-only packages importable via require() (experimental)
# This will remove the CJS interop pain when widely adopted
# Timeline: Node.js 24+ likely to make this stable
Package Author Guidance for 2026
For npm package authors, the 2026 recommendation is clear: publish dual packages (ESM + CJS) with exports field, or go ESM-only if your minimum Node.js requirement is 18+.
The dual-package approach requires building two output formats but maximizes compatibility. Use tsup, tsdown, or unbuild to generate both dist/index.mjs (ESM) and dist/index.cjs (CJS), then declare them in package.json exports:
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
}
}
This configuration lets both import statements and require() calls resolve to the correct format without dual-package hazards (two instances of a singleton). Validate the output with publint before publishing to catch common misconfiguration issues.
For app developers (not library authors), the transition is simpler: if your Next.js, Remix, or Vite app is already bundling dependencies, ESM-vs-CJS is a bundler concern, not yours. Focus on ensuring your direct imports resolve correctly; the bundler handles the rest.
TypeScript and the ESM Build Pipeline
TypeScript projects have additional complexity in the ESM transition because TypeScript compiles to JavaScript before the module system is resolved. When "module": "ESNext" or "module": "Node16" is set in tsconfig.json, TypeScript emits ES module syntax (import/export) in the output files, but the Node.js runtime's choice of CJS or ESM depends on the type field in package.json or the file extension (.mjs for ESM, .cjs for CJS, .js for whatever the package type declares). A common mistake is setting "module": "ESNext" in TypeScript without adding "type": "module" to package.json, which produces ESM syntax that Node.js then executes in CJS mode — resulting in SyntaxError for import statements. The recommended TypeScript configuration for ESM packages uses "module": "Node16" or "moduleResolution": "Node16", which also enforces explicit file extensions in import paths (you must write import { foo } from './utils.js' even though the source file is utils.ts), matching how Node.js ESM resolution works. Build tools like tsup abstract these details by handling compilation and exports field generation automatically.
The Ecosystem-Wide ESM Timeline and What It Means
The ESM transition has been ongoing for five years, and 2026 represents a meaningful inflection point where the ecosystem is past the painful middle phase. The most impactful ESM-only migrations have already happened — chalk, node-fetch, got, and the UnJS ecosystem packages (unenv, defu, destr) have all moved to ESM-first or ESM-only, and the JavaScript community has largely adapted by pinning to pre-ESM versions or migrating their own code. Node.js 22's experimental require(ESM) capability, if stabilized in Node.js 24 as expected, will eliminate the last class of CJS-from-ESM interop issues, making the module format distinction largely invisible for application developers. For the remaining 25% of top-100 packages still shipping only CJS in 2026, the pressure to add ESM exports will continue as Bun, Deno, and edge runtime adoption grows — these environments have stronger preferences for native ESM than Node.js historically has had.
Tooling Support and bundler behavior
Modern bundlers (Vite, Rollup, esbuild) handle ESM and CJS interoperability transparently for application developers, which is why many frontend teams have not felt the ESM transition directly. Vite resolves dependencies using a package's exports field, preferring the import condition when available and falling back to the main field for CJS packages. esbuild similarly prefers ESM when the exports field has an import condition. The result is that most React and Vue application developers using Vite have been consuming ESM dependencies for years without being aware of it, because the bundler handles the format translation. The remaining pain points are in Node.js-specific contexts — CLI tools, server-side code, test runners, and build scripts — where the module format affects the actual runtime execution environment rather than being an intermediary step in a bundler pipeline.
Migrating an Application Codebase to ESM
For development teams considering an application-level migration from CJS to ESM, the process is more manageable than package-level migration because you only need to satisfy one runtime environment rather than multiple consumers. The migration steps are: add "type": "module" to package.json, rename any files that should remain CJS to .cjs, update all require() calls to import statements, replace __dirname and __filename with import.meta.url derivations, and audit dependencies for any that require dynamic import() at module boundaries. The most common blocker in application migrations is test runner compatibility — Jest historically required CJS and had limited ESM support, which drove many teams to pin their applications to CJS. Vitest's native ESM support has significantly reduced this blocker, and the --experimental-vm-modules flag in Jest allows ESM test execution for teams that cannot migrate off Jest.
Compare package formats and ecosystem health at PkgPulse.
Interoperability Patterns for Mixed Codebases
Many production codebases in 2026 contain a mix of ESM and CJS modules, either because migration is incremental or because dependencies haven't fully transitioned. The import() dynamic import expression is the primary interoperability bridge — CJS modules can use import() to consume ESM-only packages in async contexts, even though require() cannot load ESM. This works in any async function, including Node.js top-level await when the consuming file is .mjs or in a package with "type": "module". The reverse direction (ESM consuming CJS) works naturally — import express from 'express' works even though Express ships CJS. Node.js's CJS-to-ESM interop wraps the CJS module's module.exports as the default export, which is why named imports from CJS packages (import { Router } from 'express') work through namespace analysis. Understanding these interoperability rules prevents the most common errors when working in mixed codebases.
Package Validation and Publishing Best Practices
Publishing a package with incorrect module format configuration is a common source of downstream breakage that affects all consumers of the package. The publint tool (run via npx publint) validates a package's exports field against the actual files on disk, checking that every declared export path exists and that the format hints (require, import) match the actual module syntax in the file. Running publint before every publish catches mismatches like declaring an import condition that points to a file with CommonJS module.exports syntax — a configuration that appears correct in package.json but fails at runtime for ESM consumers. The are-the-types-wrong tool from arethetypeswrong.github.io catches TypeScript-specific issues: double declarations, incorrect moduleResolution assumptions, and type definition files that don't match the declared exports. Both tools run in under a second and should be added to pre-publish scripts alongside the test suite. For the package format field itself, omitting "type": "module" and using .mjs and .cjs file extensions explicitly is the safest approach for dual packages, as it makes the format unambiguous regardless of the consuming environment's package configuration.
See also: AVA vs Jest and The Great Migration: CJS to ESM in the npm Ecosystem, ESM Migration Guide: CommonJS to ESM 2026.
See the live comparison
View bun vs. node on PkgPulse →