<!-- PkgPulse AI-readable guide source -->
<!-- Canonical: https://www.pkgpulse.com/guides/esm-vs-cjs-adoption-gap-npm-ecosystem -->
<!-- Raw Markdown: https://www.pkgpulse.com/guides/esm-vs-cjs-adoption-gap-npm-ecosystem/raw.md -->
<!-- Source path: content/guides/esm-vs-cjs-adoption-gap-npm-ecosystem.mdx -->

---
og_image: "/images/guides/esm-vs-cjs-adoption-gap-npm-ecosystem.webp"
title: "The ESM vs CJS Adoption Gap Across npm 2026"
description: "Where does the npm ecosystem stand on ESM vs CommonJS in 2026? Data on ESM adoption rates, which packages are ESM-only vs dual, and what the transition means."
date: "2026-03-08"
author: "PkgPulse Team"
tags: ["esm", "commonjs", "nodejs", "npm", "javascript", "2026"]
featured_comparison: "bun-vs-node"
noindex: true
---

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

```bash
# 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

```javascript
// 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

```javascript
// 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

```json
// 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"
    }
  }
}
```

```javascript
// 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

```json
// 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"
    }
  }
}
```

```typescript
// 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

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

```json
{
  "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](https://www.pkgpulse.com/compare/bun-vs-node).*

## 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](/compare/ava-vs-jest) and [The Great Migration: CJS to ESM in the npm Ecosystem](/guides/great-migration-cjs-to-esm-npm-ecosystem-2026), [ESM Migration Guide: CommonJS to ESM 2026](/guides/esm-migration-guide-commonjs-to-esm-2026).*
