When a CommonJS consumer tries to load a pure ESM dependency, Node.js (v12+) immediately throws:

Error [ERR_REQUIRE_ESM]: require() of ES Module /path/to/pkg/dist/index.js not supported.
Instead change the require of /path/to/pkg/dist/index.js to a dynamic import() which is
available in all CommonJS modules.

This error is not a configuration hint — it is a hard stop. The runtime refuses to evaluate ESM source through a synchronous require() call because ESM modules are parsed asynchronously and their bindings are live, not copied. The fix is a two-part process: route CJS consumers to a valid CommonJS artifact via the exports field, and — when no CJS artifact exists — provide a dynamic import wrapper that bridges the async boundary.


Root Cause

This error surfaces because of the dual-package hazard in Node.js resolution: a package can be loaded as ESM or as CJS, but not both in the same synchronous call chain. When a package sets "type": "module" or ships only .mjs files, Node.js marks every entry as ESM-only. Any downstream require() call hits the ESM guard and throws immediately — there is no implicit conversion path.

The exports map is Node.js’s mechanism for directing consumers to the right artifact per resolution context. Without an explicit "require" condition, Node.js has no CJS entry to fall back to, and the error is unavoidable regardless of how the consumer is configured.


Minimal Reproduction

The following package structure triggers ERR_REQUIRE_ESM in any CJS consumer:

{
  "name": "my-esm-lib",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.js",
  "exports": {
    ".": "./dist/index.js"
  }
}
// consumer.cjs  — triggers ERR_REQUIRE_ESM
const lib = require('my-esm-lib');

The "type": "module" declaration makes dist/index.js an ES module. The exports map has no "require" condition, so Node.js resolves the single "." entry — which is an ESM file — and throws when require() tries to load it synchronously.


SVG: How Node.js Resolves the exports Map

Node.js exports map resolution flowchart A flowchart showing how Node.js evaluates the exports field. A require() call checks for a require condition first; if absent it falls through to the default condition. If the resolved file is ESM, ERR_REQUIRE_ESM is thrown. An import() call checks for the import condition first, then default. require('my-lib') CJS consumer import 'my-lib' ESM consumer exports map evaluated top → bottom "require" condition present? "import" condition present? Resolves to .cjs ✓ loads fine YES Falls to "default" (often ESM file) NO ERR_REQUIRE_ESM thrown at runtime Resolves to .mjs ✓ loads fine YES

Step-by-Step Fix

Step 1 — Audit the current package.json

Run this one-liner to surface every field that affects resolution:

node -e "const p=require('./package.json'); console.log(JSON.stringify({type:p.type,main:p.main,module:p.module,exports:p.exports},null,2))"

You are looking for three things: whether "type": "module" is set, whether "main" points to an ESM file, and whether "exports" is missing a "require" condition.

Before (broken):

{
  "type": "module",
  "main": "./dist/index.js",
  "exports": {
    ".": "./dist/index.js"
  }
}

Step 2 — Add a CJS build output

If your bundler only emits ESM today, configure it to also emit a CJS artifact. With tsup, the change is a single line:

// tsup.config.ts
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'],   // add 'cjs' here
  dts: true,
  splitting: false,
  sourcemap: true,
  clean: true,
});

tsup emits dist/index.js (ESM) and dist/index.cjs (CJS). Rollup users add { format: 'cjs', file: 'dist/index.cjs' } to the output array.

Step 3 — Wire the exports map with correct condition ordering

Update package.json so the exports map routes each consumer type to the right artifact. Condition keys are evaluated top-to-bottom; "types" must come first so TypeScript finds declarations before Node.js touches the runtime fields:

After (fixed):

{
  "name": "my-esm-lib",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "default": "./dist/index.js"
    },
    "./package.json": "./package.json"
  }
}

Key changes from the broken version:

  • "main" removed — the exports map takes full control; the legacy main field can shadow it on older runtimes.
  • "require" condition added, pointing to dist/index.cjs.
  • "types" is first, so TypeScript resolves declarations correctly without moduleResolution: bundler.
  • "default" is last, acting as a safe fallback for environments that do not match earlier conditions.

HAZARD PREVENTION: Do not place "default" before "require" or "import". Node.js and bundlers stop at the first matching condition. If "default" comes first, every consumer — including CJS callers — resolves through it, bypassing your explicit conditions entirely.

Step 4 — Dynamic import wrapper (when no CJS build is possible)

If producing a CJS artifact is impractical (for example, your package uses top-level await throughout), you can provide a thin synchronous-seeming wrapper. The wrapper cannot be truly synchronous — ESM loading is async — but it can expose an initializer that CJS consumers call once:

// src/wrapper.cts  — compiled as CommonJS (.cjs output)
// Exposes an async init() so CJS consumers can await the ESM core.
let cached: typeof import('./index.js') | undefined;

export async function init() {
  if (!cached) {
    cached = await import('./index.js');
  }
  return cached;
}

Wire it into the exports map as the "require" condition:

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/wrapper.cjs",
      "default": "./dist/index.js"
    }
  }
}

CJS consumers then call:

// consumer.cjs
const { init } = require('my-esm-lib');
const lib = await init();
lib.doSomething();

Cache the result at module scope in the consumer to avoid repeated dynamic imports.


Verification Command

After updating package.json and rebuilding, confirm both resolution paths resolve correctly:

# Verify ESM path
node --input-type=module --eval "import('my-esm-lib').then(m => console.log('ESM OK', Object.keys(m)))"

# Verify CJS path
node --eval "const lib = require('my-esm-lib'); console.log('CJS OK', typeof lib)"

# Full exports validation with publint
npx publint

# Type-resolution check across all conditions
npx attw --pack .

Expected output from publint when the map is correct:

✓ "exports['.']['types']" is valid.
✓ "exports['.']['import']" is valid.
✓ "exports['.']['require']" is valid.
✓ No issues found.

Expected output from attw when types are wired correctly:

my-esm-lib
  node10    CJS 🟢
  node16    ESM 🟢  CJS 🟢
  bundler   ESM 🟢

A red or missing row means attw cannot resolve types for that consumer type — check that your "types" condition points to the correct .d.ts file.


Edge Cases / Gotchas

  • npm vs pnpm symlink layouts. pnpm’s strict symlinking blocks deep imports not listed in "exports". If you relied on internal paths (e.g., require('my-lib/internals')), add explicit subpath exports or CJS consumers will get ERR_PACKAGE_PATH_NOT_EXPORTED.
  • Yarn PnP. Yarn Plug’n’Play rewrites resolution via a zip-based virtual filesystem. Test with yarn dlx attw --pack . as well as npx attw --pack . because the two may produce different results if your exports map has gaps.
  • TypeScript moduleResolution: node. The legacy node resolution mode ignores exports entirely — TypeScript reads only types, typings, and main. Consumers must upgrade to moduleResolution: node16, nodenext, or bundler to benefit from your conditions. Document this in your README.
  • Vite vs webpack conditionNames. Vite adds "browser" and "development" conditions by default; webpack adds "browser", "module", and "webpack". If your map includes these, verify Vite and webpack both see the intended artifact and not the "default" fallback.
  • Stale module cache after reinstall. Node.js caches resolved module specifiers. After changing exports, run rm -rf node_modules/.cache and reinstall before testing — especially in monorepos where workspaces may carry a cached resolution snapshot.
  • Top-level await in the wrapper. The .cts wrapper file must not use top-level await — CommonJS does not support it. Move all await into the init() function body.

FAQ

Why do I still get ERR_REQUIRE_ESM after adding a require condition to exports?

The legacy "main" field can shadow the exports map for Node.js versions below v12.17. Remove "main" entirely or ensure it points to your CJS artifact, not the ESM entry. Also clear node_modules/.cache and reinstall — stale resolution snapshots from npm or yarn persist across installs.

Can I ship a pure ESM package with no CJS fallback at all?

Yes, but you must communicate this clearly. Set "type": "module" and omit any "require" condition from exports. Document that consumers using toolchains that call require() synchronously will need to migrate. Popular ESM-only packages such as chalk v5 and node-fetch v3 ship this way successfully.

Why does my dynamic import wrapper return a Module object instead of my exports?

Dynamic import() always resolves to a Module namespace object. If your ESM source uses a default export, consumers must destructure: const { default: myLib } = await import('my-lib'). Your wrapper can flatten this by returning mod.default ?? mod so CJS consumers receive a familiar shape.

Does this fix work with pnpm and Yarn PnP?

Yes, but test both. pnpm’s strict symlink layout means deep imports blocked by your exports map will fail even if they accidentally resolve under npm. Yarn PnP rewrites resolution entirely — test your conditional exports under nodeLinker: pnpm and nodeLinker: node-modules to cover both modes.

Will TypeScript correctly infer types through a CJS dynamic import wrapper?

Only if your exports map includes a "types" condition pointing to a .d.ts file for the CJS entry. Use "types" as the first key in each condition object (TypeScript evaluates it first). Tools like attw will flag missing type conditions — always run npx attw --pack . before publishing.



↑ Back to Navigating the Dual-Package Hazard