Using tsup to Bundle Dual ESM and CJS Outputs
Configure tsup to emit parallel ESM and CJS bundles with TypeScript declarations. Set correct package.json exports conditions and validate the output with publint.
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.
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: trueonly applies to ESM. Code splitting produces multiple ESM chunks via dynamicimport()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: trueis for Node.js targets only. Enablingshims: trueinjects polyfills for__dirname,__filename, andrequireinto the.mjsoutput. For browser-targeted builds this will throw at runtime becauseimport.meta.urlsemantics differ in browsers. -
pnpm hoisting and
exportscompatibility. pnpm’s strictnode_moduleslayout means consumers cannot reach files not listed inexports. After adding dual outputs, double-check thatexportslists every public entry point — pnpm will surfaceERR_PACKAGE_PATH_NOT_EXPORTEDfor 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, usedts: { resolve: true }to inline those external types. Pair it with an explicitexternallist 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.jsextensions on relative imports in source. tsup handles the rewrite for you, but if you author source with extensionless imports and use"bundler"resolution in yourtsconfig.json, confirm that optimizing yourtsconfig.jsonfor library distribution matches your target consumers’ resolution mode. -
Webpack 4
conditionNamesdoes not includeimport. Consumers using Webpack 4 will fall through todefaultin your exports map. Ensuredefaultpoints 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.
Related
- Modern Build Tools: tsup, Rollup, and esbuild — parent overview comparing tsup, Rollup, and esbuild trade-offs for library authors.
- Fixing
require()Errors in Pure ESM Packages — broader treatment ofERR_REQUIRE_ESMroot causes and wrapper shim patterns. - Mastering the
package.jsonexports Field — deep reference on condition key ordering, subpath patterns, and compatibility with older bundlers.