How to Ship ESM-Only Packages Without Breaking Your Users
This guide belongs to Modern Module Formats: ESM vs CommonJS, within JavaScript Bundle Optimization & Code Splitting. For years, library authors published both CommonJS and ES module builds — "dual packages" — to serve both Node's require and bundlers' import. That doubled build output, invited the dual package hazard (two copies of a module loaded at once), and kept CommonJS around long after browsers and bundlers moved on.
The ecosystem has shifted. Every modern bundler consumes ESM natively and tree-shakes it far better than CommonJS. Node supports ESM fully, and recent Node versions can require() synchronous ES modules, removing the biggest historic blocker. For many packages — especially frontend libraries consumed through bundlers — shipping ESM only is now the simpler and faster choice.
Rapid Diagnosis
- Check who consumes the package. Frontend apps through bundlers: ESM-only is straightforward. Node tooling, Jest without ESM support, or old Electron apps: check compatibility first.
- Look at your
exportsmap. Separateimportandrequireconditions pointing at different files indicate a dual package. - Search consumer bundles for both builds. If both
dist/index.cjsanddist/index.mjsof your package appear in an app's bundle, the dual package hazard is live. - Check Node version support policy. Node 20.19+/22.12+ can
require()ES modules without top-level await; older Node versions cannot.
Root Cause Analysis
1. CommonJS limits static analysis. require and module.exports are dynamic; bundlers must keep whole modules, so consumers ship more code.
2. Dual builds create two module instances. When some code paths import and others require the same package, two copies load — doubling bytes and breaking singletons (contexts, registries, instanceof checks).
3. Interop wrappers add weight. Bundlers wrap CommonJS modules in interop helpers to emulate ESM semantics, adding bytes and runtime cost.
4. Maintenance burden. Two outputs, two sets of types and conditional exports mean more ways to publish a broken package.
Step-by-Step Resolution
1. Declare the package as ESM with a clean exports map
{
"name": "@acme/utils",
"type": "module",
"exports": {
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"./format": { "types": "./dist/format.d.ts", "default": "./dist/format.js" }
},
"sideEffects": false,
"engines": { "node": ">=20.19" }
}
The engines field documents the minimum Node version able to require() the package; bundler consumers are unaffected. Trade-off: consumers on older Node who require() the package will get an error and must upgrade or switch to import.
Expected outcome: one module format, explicit public entry points, and accurate side-effect information.
2. Avoid top-level await in public modules
Node's require(esm) works only for modules without top-level await. Keep TLA out of anything a CommonJS consumer might load.
// Avoid in library entry points:
// const config = await loadConfig();
// Prefer an explicit async initialiser:
export async function init(options) { /* ... */ }
// trade-off: callers must call init() before use. Document it, and throw a
// clear error from functions called before initialisation.
Expected outcome: the package is loadable from both import and modern require.
3. Release it as a major version with a migration note
Removing CommonJS is a breaking change for some consumers. Publish a major version, document the minimum Node version, and keep the last dual release maintained for critical fixes for a period.
4. Verify in real consumers
Test the package from a Vite app, a webpack app, a Next.js app and a Node script using both import and require. Check that each bundle contains only the ESM files and only the imported functions.
# Quick Node compatibility check for require(esm).
node -e "const u = require('@acme/utils'); console.log(typeof u.format)"
# trade-off: this only proves the entry loads; run your consumer test suites
# against the new version before announcing the release.
Verification
In consumer applications, check bundle analysis: the package's files should appear once, with only the imported functions. Confirm no __esModule interop wrappers for your package. Run the consumers' tests. For Node consumers, confirm both import and require work on the supported Node versions.
Worked Example: A Shared Component Utilities Package
An internal utilities package published dist/cjs and dist/esm. A Next.js app imported it from client components (ESM path) while a server-side helper used require (CJS path); both copies ended up in the client bundle through a shared module, adding 51KB and breaking a React context because the provider and consumer came from different copies. The team released an ESM-only major with a single exports entry, removed a top-level await from a config loader, and set engines.node to >=20.19. Bundle size for consumers dropped by the full duplicate plus 9KB of interop helpers, and the context bug disappeared.
Common Mistakes
- Forgetting
"type": "module"..jsfiles are then treated as CommonJS by Node, and imports fail. - Leaving
mainpointing at a removed CJS file. Old tooling readsmain; point it at the ESM entry or remove it. - Publishing TypeScript declarations without
typesconditions. Consumers usingmoduleResolution: "bundler"or"node16"need them inexports. - Using
__dirnameandrequirein ESM source. Replace withimport.meta.urlandimport.
Edge Cases
Jest and other CommonJS-first tools. Older Jest setups transform test code to CommonJS and may fail to load ESM-only dependencies without configuration (transformIgnorePatterns or native ESM mode). Consumers using such tools may need to update their test configuration; mention it in the migration note.
Electron and older Node runtimes. Applications pinned to older Node versions cannot require() ESM; they must use dynamic import(), which is asynchronous. Decide whether those consumers are in scope.
JSON imports. ESM requires import attributes for JSON (import data from './data.json' with { type: 'json' }); bundlers handle it, but Node consumers need a supporting version. Prefer exporting data from a .js module.
CDN consumption. ESM-only packages work well from ESM CDNs (esm.sh, jsDelivr's ESM endpoints) and with import maps, as described in using import maps for unbundled production.
FAQ
Is ESM-only right for every package?
For browser-focused libraries consumed through bundlers, almost always. For CLI tools and server libraries with many CommonJS consumers on older Node versions, a transition period with dual publishing may still be kinder. The trend is clearly towards ESM-only.
Does ESM-only improve runtime performance, or only bundle size?
Mostly bundle size and correctness. Smaller bundles mean less parse and compile work, which does improve startup in consuming apps. The module format itself has little runtime cost difference once bundled.
What is the dual package hazard exactly?
It is the situation where both the CommonJS and ESM builds of one package load in the same program, creating two instances of its module state. Singletons, caches and instanceof checks break, and bytes double. Fixing dual package hazard in a library covers mitigation if you must stay dual.
Should I bundle my ESM output into one file?
For libraries, prefer preserving modules so consumers' bundlers can drop unused files; see building tree-shakeable libraries with Vite library mode.
Do I still need a CommonJS build for TypeScript users?
No. TypeScript resolves ESM packages through the exports map with moduleResolution set to bundler, node16 or nodenext. Projects still on the legacy node resolution mode may fail to find types; the fix on their side is updating moduleResolution, which is worth stating in your release notes.
Related
- Writing tree-shakeable library exports — the export design that ESM makes effective.
- Top-level await and module loading cost — why TLA matters for loading and interop.
- Modern module formats: ESM vs CommonJS — the broader comparison.