Back to core workflows Fix dependency resolution Tune package metadata Jump to monorepo patterns

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:

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: 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:
Exact Symptoms and Pipeline Failures — the core idea of this section at a glance.
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.

Dual-module resolution branch A consumer's load style selects the import or require condition, each pointing at its matching artifact and declaration file. consumer load import or require "import" index.js (ESM) index.d.ts "require" index.cjs (CJS) index.d.cts no format mismatch error
Each load style selects its own condition; both branches must point at a format-matching artifact and its declaration file to avoid a mismatch error.

A dual-module misconfiguration fails because the exports map is a strict, ordered contract that Node and TypeScript both evaluate the same way, and any inconsistency between the conditions and the files they point at surfaces only for a consumer. When a consumer imports the package, the resolver walks the import condition; when they require it, the require condition; and the type checker under modern resolution follows the same branches. If the require condition points at an ESM file, or a types condition is missing from a branch, the JavaScript or the types resolve wrong even though your own source-importing tests pass.

The deeper cause is that the manifest describes an artifact you have not necessarily produced correctly. The exports map promises a .cjs under require and a .mjs under import, each with a matching declaration, but nothing checks that the build actually emitted those files in those formats until a consumer resolves them. This is why a dual-module setup must be validated against the packed tarball the way a consumer resolves it, not against the source, which resolves directly and never exercises the conditions.

Resolution and Configuration Patch

Apply the following package.json to establish deterministic dual-module resolution:

Resolution and Configuration Patch Apply the following package.json to establish deterministic dual-module resolution: Resolution and Configuration Patch Apply the following package.json to establish deterministic dual-module resolution:
Resolution and Configuration Patch — the core idea of this section at a glance.
{
  "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:

  1. Set the baseline format. Add "type": "module" so bare .js files are treated as ESM; the CJS artifact then uses the explicit .cjs extension.
  2. Nest the conditions. Under import and require, list types first and default second so type checkers resolve declarations before the runtime file. Keep a top-level default as a final fallback.
  3. Retain legacy fields. Keep main (CJS) and module (ESM) for toolchains that predate the exports map. Drop them only when you fully control the consumer environment.
  4. Align the build output. Configure tsup, Rollup, esbuild, or tsc to emit .js/.d.ts into dist/esm and .cjs/.d.cts into dist/cjs, matching the paths above. The dual-declaration half of this is detailed in Generating Dual CJS/ESM Type Definitions.

A correct dual-module manifest nests a types condition inside each runtime branch and keeps top-level fields as legacy fallbacks:

{
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": { "types": "./dist/index.d.ts", "default": "./dist/index.mjs" },
      "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
    }
  },
  "files": ["dist"],
  "sideEffects": false
}

The nested types conditions make the checker resolve .d.ts for import and .d.cts for require; the top-level main/module/types serve legacy resolvers that ignore exports; and sideEffects: false lets consumers tree-shake the ESM build.

CLI Validation and Debug Commands

Verify resolution in an isolated consumer environment before publishing:

CLI Validation and Debug Commands Verify resolution in an isolated consumer environment before publishing: CLI Validation and Debug Commands Verify resolution in an isolated consumer environment before publishing:
CLI Validation and Debug Commands — the core idea of this section at a glance.
# 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.

Validate the packed package the way a consumer resolves it, across every module mode:

# Structural checks on the exports map
pnpm exec publint
# Resolution grid across import/require/bundler/node16
pnpm exec attw --pack .
# Confirm both formats and both declarations emitted
ls dist/index.mjs dist/index.cjs dist/index.d.ts dist/index.d.cts

A clean attw grid confirms every resolution mode finds a correct file and declaration; a missing cell names exactly which condition or declaration is wrong.

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" in exports to prevent metadata read errors from tools that inspect the manifest at runtime.
  • Run a dual-consumer smoke test before publish — a prepublishOnly script that both imports and require()s the built artifacts.
  • Never reuse a .js extension for both formats. Route explicitly through .cjs/.mjs or separate cjs/ and esm/ directories to eliminate parser ambiguity.
Prevention and CI/CD Guardrails Prevention and CI/CD Guardrails in production JavaScript package workflows. Prevention and CI/CD Guardrails Prevention and CI/CD Guardrails in production JavaScript package workflows.
Prevention and CI/CD Guardrails — the core idea of this section at a glance.

Verifying the dual build in CI

The dual-module setup is only correct if it is verified against the artifact, so a CI job should resolve the packed package the way a consumer will and fail on any mismatch. Running publint for structural exports checks and @arethetypeswrong/cli for the full resolution grid, plus a smoke test that both imports and requires the built package and type-checks under node16, exercises every combination a consumer might hit. A reordered condition, a missing .d.cts, or a require branch pointing at an ESM file then fails the pull request that introduced it rather than a consumer's install weeks later.

Verify the dual build publint and attw on the tarball, plus a node16 smoke test. publint exports structure attw --pack resolution grid require + import node16 smoke test
Validate the artifact as a consumer resolves it, across every module mode.

The reason this matters so much for dual modules specifically is that the failure modes are all invisible in your own repository. Your tests import the source directly, so they never resolve through the published conditions; only a consumer, or a CI check that mimics one, exercises the require branch and the type resolution under strict modes. Making artifact validation a gate — against the tarball, across every module mode — is what turns a dual-module package from one that works in your tests and breaks for consumers into one that provably resolves correctly everywhere.

The dual-package hazard in a dual-module build

A dual-module build introduces a hazard that a single-format package does not have: if a consumer's dependency graph reaches your package through both import and require, Node instantiates the ESM and CJS builds as separate modules, each with its own state. For a stateless utility this is merely wasteful, but any singleton — a registry, a cache, a class checked with instanceof — now exists twice and desynchronizes, producing bugs that resist debugging because both copies are individually correct.

Dual-package hazard Duplicated singleton versus one shared module. Two builds, two states • import + require load separately • singleton doubled • instanceof fails One shared module • single .cjs both require • one instance • state in sync
Keeping identity-bearing state in one module both builds reference guarantees one instance.

The defense is to keep any identity-bearing state in a single module both builds reference — commonly a small CommonJS file both require — so it is instantiated once regardless of entry format, while the rest of the package stays dual-format. Where you do not need synchronous require support at all, shipping ESM-only removes the hazard by construction, since a single-format package cannot load twice. Deciding this at design time, and testing that a package loaded both ways yields a single instance, is what keeps a dual-module build from silently doubling shared state.

Choosing between dual-module and ESM-only

Before configuring a dual-module build, it is worth deciding whether you need one at all, because ESM-only is increasingly viable and structurally simpler. A dual build ships both formats so that CommonJS consumers can require the package synchronously, which matters for older tooling and for libraries with a broad, conservative audience. ESM-only ships one format, which eliminates the dual-package hazard by construction and halves the build and validation surface, at the cost that CommonJS-only consumers must reach it through a dynamic import() bridge.

Format choice Whether to ship dual-module or ESM-only. Do consumers need synchronous require? no ESM-only yes, broad audience dual build modern only ESM-only
ESM-only is simpler and hazard-free; ship dual only for CommonJS consumers.

The decision comes down to audience. A library targeting modern runtimes and bundlers, where consumers are already ESM or can use a dynamic import, is well served by ESM-only — it is simpler to build, simpler to validate, and free of the singleton-duplication risk. A widely-depended-on package that must run everywhere, including in CommonJS codebases that cannot easily adopt a dynamic import, still benefits from a dual build despite its extra complexity. Making this choice deliberately, rather than reaching for a dual build by default, avoids carrying the dual-module configuration and its hazards when a single format would serve your consumers just as well.

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.

Why do I need both .d.ts and .d.cts for a dual-module package?

Under node16/nodenext resolution the type checker follows the same conditional exports as the runtime, so the require branch needs a CommonJS-flavored .d.cts beside its .cjs. A single shared .d.ts leaves require consumers with wrong-shape types even when the JavaScript resolves correctly.

How do I confirm my dual-module config is correct?

Validate the packed tarball with publint and @arethetypeswrong/cli, plus a smoke test that imports and requires the build and type-checks under node16. These resolve the package as a consumer will, so a broken condition fails your CI rather than their install.

Can a dual-module package load its state twice?

Yes — the dual-package hazard. If a consumer reaches it through both import and require, the two builds instantiate separately and any singleton doubles. Keep identity-bearing state in one shared module both builds reference, or ship ESM-only.

Should I ship dual-module or ESM-only?

ESM-only for a library targeting modern runtimes — it is simpler to build and validate and has no dual-package hazard. Ship a dual build only when you must support CommonJS consumers that cannot use a dynamic import() bridge, accepting the extra configuration and the singleton-duplication risk.

Do I still need main and module if I have an exports map?

Modern resolvers use exports exclusively and ignore main/module, but keeping main (and module) pointing at the same CJS/ESM entries as your conditions provides a fallback for older tooling that predates exports. Treat exports as the source of truth and the top-level fields as legacy compatibility.

Related

Understanding package.json Fields