When a CommonJS consumer runs require('@your-scope/lib') against an ESM-only artifact, Node.js throws ERR_REQUIRE_ESM and execution halts. The root fix is not a workaround — it is publishing two parallel artifacts: a .mjs file for ESM consumers and a .cjs file for CJS consumers, with the exports field routing each runtime to the correct artifact automatically. tsup makes this a single config option rather than two separate build scripts.

Root Cause

ERR_REQUIRE_ESM fires because Node.js’s require() loader is synchronous and cannot execute the async import() protocol that ESM uses internally. Any package whose main or exports.require condition points to a file containing export syntax — or a file in a directory where the nearest package.json declares "type": "module" — will trigger this error in CJS callers. The dual-package hazard compounds this: if you ship the same singleton (e.g. a React context or a logger instance) in both formats without isolation, CJS and ESM consumers may each load a separate copy, silently breaking identity checks.

tsup sidesteps both problems by compiling your TypeScript entry point into format-specific artifacts in one pass, automatically choosing .mjs for ESM and .cjs for CJS so their type is unambiguous regardless of the "type" field.

How tsup Routes Format to Extension

The diagram below shows how a single tsup.config.ts produces the two artifacts that package.json exports then routes to the correct consumer.

tsup dual-format build pipeline Diagram showing src/index.ts entering tsup, which fans out to dist/index.mjs for ESM consumers and dist/index.cjs for CJS consumers, with package.json exports routing each. src/index.ts TypeScript source tsup format: [esm, cjs] dist/index.mjs ESM · import condition dist/index.cjs CJS · require condition + dist/index.d.ts

Minimal Reproduction

The smallest project that triggers ERR_REQUIRE_ESM has this layout:

my-lib/
  src/index.ts
  package.json        ← "type": "module", no exports.require condition
  tsup.config.ts      ← format: ['esm']  (only ESM, no CJS)

package.json (broken — missing CJS output):

{
  "name": "my-lib",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.js"
    }
  }
}

A CJS consumer calling const lib = require('my-lib') hits ERR_REQUIRE_ESM because there is no require condition and the only artifact is ESM.

Step-by-Step Fix

Step 1 — Add both formats to tsup.config.ts

Before:

import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm'],   // ← ESM only
  dts: true,
  clean: true,
});

After:

import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'],   // ← both formats
  dts: true,
  splitting: true,   // chunk deduplication for ESM
  clean: true,
  external: ['react', 'react-dom'],   // keep peer deps out of the bundle
});

When both formats are listed, tsup appends .mjs to the ESM artifact and .cjs to the CJS artifact. The extension alone makes the module type unambiguous — Node.js treats .mjs as ESM and .cjs as CJS regardless of the "type" field in package.json.

Step 2 — Wire exports to both artifacts

Before (single condition, no CJS route):

{
  "exports": {
    ".": {
      "import": "./dist/index.js"
    }
  }
}

After (both conditions, types listed first):

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "default": "./dist/index.mjs"
    }
  },
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts"
}

The types condition is listed first so TypeScript resolves the declaration file before falling through to the runtime artifacts — publint and arethetypeswrong flag the reverse order as an error. The main fallback catches bundlers that ignore exports entirely (Webpack 4, older Jest configs).

Step 3 — Build

npx tsup

Expected output after a clean build:

CLI Building entry: src/index.ts
CLI Using tsconfig: tsconfig.json
ESM dist/index.mjs      1.23 kB
CJS dist/index.cjs      1.45 kB
DTS dist/index.d.ts     0.87 kB

Verification Command

node --check dist/index.cjs && node --check dist/index.mjs

node --check parses each file for syntax errors without executing it — a fast sanity check before running the full test suite. Follow it with publint:

npx publint

Expected passing output:

✔ No issues found

A failing output looks like:

✖ "types" field in "exports['.']" should come before the other conditions.

Fix: reorder the keys in package.json so types is always first.

Run arethetypeswrong (attw) to confirm TypeScript resolves declarations correctly under every moduleResolution mode:

npx @arethetypeswrong/cli --pack .

All entries should show No problems found. An ESM (index.d.ts) entry showing Resolution failed means TypeScript cannot find the declaration file — check that dts: true ran and that the types path in exports matches the actual file.

Edge Cases and Gotchas

  • splitting: true only applies to ESM. Code splitting produces multiple ESM chunks via dynamic import() but CJS output is always a single file. If your entry re-exports from many sub-paths, the CJS bundle can be large — that is expected.

  • shims: true is for Node.js targets only. Enabling shims: true injects polyfills for __dirname, __filename, and require into the .mjs output. For browser-targeted builds this will throw at runtime because import.meta.url semantics differ in browsers.

  • pnpm hoisting and exports compatibility. pnpm’s strict node_modules layout means consumers cannot reach files not listed in exports. After adding dual outputs, double-check that exports lists every public entry point — pnpm will surface ERR_PACKAGE_PATH_NOT_EXPORTED for any path not in the map.

  • dts: { resolve: true } for complex type dependencies. When your public API references types from packages that consumers may not have installed, use dts: { resolve: true } to inline those external types. Pair it with an explicit external list to avoid pulling React’s entire type tree into your declarations:

    export default defineConfig({
      entry: ['src/index.ts'],
      format: ['esm', 'cjs'],
      dts: { resolve: true },
      external: ['react'],
    });
  • TypeScript moduleResolution: "bundler" vs. "node16". Under "node16" or "nodenext", TypeScript requires explicit .js extensions on relative imports in source. tsup handles the rewrite for you, but if you author source with extensionless imports and use "bundler" resolution in your tsconfig.json, confirm that optimizing your tsconfig.json for library distribution matches your target consumers’ resolution mode.

  • Webpack 4 conditionNames does not include import. Consumers using Webpack 4 will fall through to default in your exports map. Ensure default points to the ESM artifact (.mjs) or a universally compatible file, not a CJS-only path.

FAQ

Why does ERR_REQUIRE_ESM still fire after I added the require condition?

Check that tsup actually wrote a .cjs file. Run ls dist/ after building — if you only see index.js and index.mjs, the CJS format did not emit. Verify format: ['esm', 'cjs'] is in your tsup.config.ts (not just the CLI flag) and that clean: true did not delete the file before you checked. Also confirm that the require condition in exports references ./dist/index.cjs and not ./dist/index.js.

Should I use .mjs/.cjs extensions or dist/esm/ and dist/cjs/ directories?

Explicit extensions are simpler with tsup. Separate directories require per-format outDir configuration and produce .js files whose interpretation depends on the "type" field of their enclosing package.json — if that file is missing or wrong, Node.js will misidentify the module format. The .mjs/.cjs approach is unambiguous at the filesystem level.

What does shims: true actually inject?

tsup injects small inline polyfills that derive __dirname and __filename from import.meta.url and create a require shim via createRequire. These run at module load time and add roughly 200 bytes to the .mjs output. They work in Node.js 18+ but do nothing meaningful in browsers.

Why does TypeScript still report errors after dts: true generates declaration files?

tsup’s dts: true calls the TypeScript compiler API in emit-only mode — it skips the type-checking pass to keep build times short. Type errors in your source do not prevent .d.ts generation. Run tsc --noEmit separately (or in CI in parallel with tsup) to catch type regressions before publishing.

My package passes publint but arethetypeswrong reports wrong types. Why?

publint validates the structural correctness of your exports map; arethetypeswrong additionally simulates TypeScript’s module resolution under "node10", "node16", "bundler", and other moduleResolution modes. A single dist/index.d.ts file shared by both the import and require conditions is the most common culprit — under "node16", TypeScript expects .d.mts alongside .mjs and .d.cts alongside .cjs. Generate format-specific declarations with dts: { entry: { index: 'src/index.ts' } } and map them in exports as "types@import" and "types@require" conditions.


Up: Modern Build Tools: tsup, Rollup, and esbuild