How to Configure package.json for Dual Modules
Publishing a single package that works for both import (ESM) and require() (CJS) consumers comes down to one correctly ordered exports map plus build output whose file extensions match it. This page walks through the exact symptoms of a broken dual-module setup, the resolution rules behind them, a minimal working manifest, and the commands that verify it before you publish.
Exact Symptoms and Pipeline Failures
You are looking at a dual-module misconfiguration if you see any of these during build, runtime, or type-checking:
Error [ERR_REQUIRE_ESM]: require() of ES Module .../index.js not supported
SyntaxError: Cannot use import statement outside a module
Module not found: Error: Can't resolve 'your-pkg' (ESM/CJS mismatch)
Could not find a declaration file for module 'your-pkg'
The first two appear when a consumer loads the wrong format for its context. The third appears when a bundler cannot pick a condition because the exports map is missing or incomplete. The fourth is the type-layer twin of the same problem and is covered in depth alongside TypeScript Declaration Publishing.
Root Cause Analysis
Node.js and modern bundlers resolve a package through its exports map, choosing the first condition that matches the consumer's context — import for ESM callers, require for CJS callers, types for type checkers. When a library publishes dual formats without an explicit, fully-conditioned exports map, consumers fall back to the legacy main field and get a single format regardless of how they loaded the package. A CJS caller then receives ESM and throws ERR_REQUIRE_ESM; an ESM caller receives CJS and may fail to find named exports. The flow below shows the branch that has to exist for both callers to land on a compatible artifact. Getting the field order and nesting right is exactly the manifest discipline described in Understanding package.json Fields, and the loader behavior it accommodates is detailed in ESM and CJS Interoperability.
Resolution and Configuration Patch
Apply the following package.json to establish deterministic dual-module resolution:
{
"type": "module",
"exports": {
".": {
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/cjs/index.d.cts",
"default": "./dist/cjs/index.cjs"
},
"default": "./dist/cjs/index.cjs"
},
"./package.json": "./package.json"
},
"main": "./dist/cjs/index.cjs",
"module": "./dist/esm/index.js",
"types": "./dist/esm/index.d.ts"
}
Apply it in order:
- Set the baseline format. Add
"type": "module"so bare.jsfiles are treated as ESM; the CJS artifact then uses the explicit.cjsextension. - Nest the conditions. Under
importandrequire, listtypesfirst anddefaultsecond so type checkers resolve declarations before the runtime file. Keep a top-leveldefaultas a final fallback. - Retain legacy fields. Keep
main(CJS) andmodule(ESM) for toolchains that predate theexportsmap. Drop them only when you fully control the consumer environment. - Align the build output. Configure tsup, Rollup, esbuild, or
tscto emit.js/.d.tsintodist/esmand.cjs/.d.ctsintodist/cjs, matching the paths above. The dual-declaration half of this is detailed in Generating Dual CJS/ESM Type Definitions.
CLI Validation and Debug Commands
Verify resolution in an isolated consumer environment before publishing:
# Validate ESM resolution
node -e "import('your-pkg').then(m => console.log('ESM OK:', typeof m.default))"
# Validate CJS resolution
node --input-type=commonjs -e "console.log('CJS OK:', typeof require('your-pkg').default)"
# Confirm the exports map resolves both conditions
node -e "console.log(require.resolve('your-pkg'))"
# Confirm LTS alignment (exports is fully supported from Node.js 14+)
node -v
If require() of the package still fails, the require branch is either missing or points at an ESM file — re-check that ./dist/cjs/index.cjs exists and is genuinely CommonJS.
Prevention and CI/CD Guardrails
- Pin Node.js versions in CI so the resolution algorithm does not shift between LTS releases mid-pipeline.
- Always expose
"./package.json": "./package.json"inexportsto prevent metadata read errors from tools that inspect the manifest at runtime. - Run a dual-consumer smoke test before publish — a
prepublishOnlyscript that bothimports andrequire()s the built artifacts. - Never reuse a
.jsextension for both formats. Route explicitly through.cjs/.mjsor separatecjs/andesm/directories to eliminate parser ambiguity.
Frequently Asked Questions
Do I still need main and module if I use exports?
For broad compatibility, yes. Bundlers and tools predating Node.js 14's exports support fall back to main and module. When you fully control the consumer environment, such as an internal monorepo on Node.js 18+, you can drop them.
How do I handle TypeScript type resolution with dual modules?
Nest a types condition inside both the import and require branches, pointing import at .d.ts and require at .d.cts, so type checkers resolve format-matching declarations without cross-contamination.
Why does ERR_REQUIRE_ESM occur even with exports configured?
Either a CJS consumer is require()-ing an ESM-only file, or the require condition is missing or points at an ESM artifact. Ensure the require branch resolves to a real CommonJS .cjs file.
Can I use a single .js file for both formats?
No. Node treats .js as CJS unless "type": "module" is set and as ESM when it is, so one file cannot satisfy both. Dual publishing requires separate .cjs/.mjs artifacts or distinct directories.
Related
- Understanding package.json Fields — the full manifest reference this
exportsmap is part of. - ESM and CJS Interoperability — the loader rules that make the dual conditions necessary.
- Generating Dual CJS/ESM Type Definitions — emitting the
.d.tsand.d.ctsfiles each branch points at. - Fixing ERR_REQUIRE_ESM in Node.js — the targeted fix when the require branch is still wrong.