Dual Packages and the Exports Field
The Package Author's Dilemma
You're publishing a package. Half your users are on ESM, half are on CJS. Some use TypeScript, some don't. Some only need one utility function from your entire library. How do you ship a package that works for everyone without creating the dual module hazard?
The answer is the exports field in package.json — the most powerful and most misunderstood feature in Node.js module resolution. It gives you fine-grained control over what consumers can import, how they import it, and which version they get based on their module system.
Think of package.json exports as a receptionist at a building entrance. Instead of letting visitors wander to any room (the old main field behavior), the receptionist directs each visitor to the right room based on who they are. CJS users go to the CJS version. ESM users go to the ESM version. TypeScript users get the type definitions. And nobody can access the internal rooms that aren't on the visitor list.
The Evolution: main → exports
The old way: main
{
"name": "my-package",
"main": "./dist/index.js"
}
The main field is a single entry point. It doesn't distinguish between CJS and ESM consumers. It can't restrict which files are importable. It's been around since the beginning of npm and it still works, but it's limited.
The new way: exports
{
"name": "my-package",
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.cjs",
"default": "./dist/esm/index.js"
}
}
}
The exports field gives you conditional resolution — different files for different contexts. When present, it also encapsulates your package: consumers can only import paths you explicitly define.
When both main and exports are present, exports wins in all Node.js versions that support it (12.11+). The main field serves as a fallback for older Node.js versions and some older bundlers.
Conditional Exports
The core power of exports is conditional resolution. Each condition is checked in order, and the first matching one wins:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.cjs",
"default": "./dist/esm/index.js"
}
}
}
The conditions Node.js supports:
// "import" → used when loaded via import or import()
// "require" → used when loaded via require()
// "node" → used when running in Node.js
// "default" → fallback, always matches (must be last)
// "types" → used by TypeScript for type resolution (must be first)
// "browser" → used by bundlers for browser builds (convention)
Order matters. Conditions are checked top to bottom, and the first match wins. "types" must come before "import" and "require" because TypeScript needs to resolve types before anything else. "default" must be last because it matches everything — any condition after it is unreachable.
The type Field — Setting Module System Defaults
The type field in package.json tells Node.js how to interpret .js files:
{
"type": "module"
}
| type value | .js files are | CJS files need | ESM files need |
|---|---|---|---|
"module" | ES Modules | .cjs extension | .js works |
"commonjs" (or absent) | CommonJS | .js works | .mjs extension |
// With "type": "module" in package.json:
// math.js → treated as ESM (import/export syntax)
// math.cjs → treated as CommonJS (require/module.exports)
// math.mjs → treated as ESM (always, regardless of type field)
// With "type": "commonjs" (or no type field):
// math.js → treated as CommonJS
// math.mjs → treated as ESM (always)
// math.cjs → treated as CommonJS (always)
Regardless of the type field, .mjs is always ESM and .cjs is always CommonJS. These extensions are the "escape hatch" when you need a file to be treated differently from the package default. Many dual packages use this to ship both formats without needing separate directories with their own package.json files.
Subpath Exports — Controlling the Public API
Without exports, consumers can import any file in your package:
// Without exports — consumers can reach into internals
import { helper } from 'my-package/dist/internal/utils.js';
// This works, but you never intended it to be public API
With exports, you define exactly what's accessible:
{
"exports": {
".": "./dist/index.js",
"./utils": "./dist/utils.js",
"./math": "./dist/math.js"
}
}
// Now consumers can do:
import { something } from 'my-package'; // maps to ./dist/index.js
import { helper } from 'my-package/utils'; // maps to ./dist/utils.js
import { add } from 'my-package/math'; // maps to ./dist/math.js
// But this is blocked:
import { internal } from 'my-package/dist/internal/utils.js';
// ERR_PACKAGE_PATH_NOT_EXPORTED
This encapsulation is powerful. You can rename internal files, restructure directories, and refactor freely — as long as the subpath exports still map to the right code, consumers are unaffected.
Subpath patterns — wildcards for large APIs
For packages with many entry points (like icon libraries or locale files), you can use wildcard patterns:
{
"exports": {
".": "./dist/index.js",
"./icons/*": "./dist/icons/*.js",
"./locales/*": "./dist/locales/*.js"
}
}
import { ArrowIcon } from 'my-package/icons/arrow';
import en from 'my-package/locales/en';
Dual Package Patterns
There are two main approaches to shipping both CJS and ESM from one package.
Pattern 1: Isolated builds (separate files)
Ship both CJS and ESM builds, use exports to route consumers:
{
"name": "my-package",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.cjs",
"default": "./dist/esm/index.js"
}
}
}
This is the most common pattern. The downside is the dual module hazard — if both the CJS and ESM versions are loaded in the same process, you get two instances.
Pattern 2: CJS wrapper (single source of truth)
Ship ESM as the real implementation, with a thin CJS wrapper that re-exports it:
// dist/index.js (ESM — the real implementation)
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
// dist/index.cjs (CJS wrapper)
module.exports = require('./esm-loader.cjs');
// or using a pattern that lazily loads the ESM version
Actually, this is tricky because require() can't load ESM directly. The real CJS wrapper pattern usually involves building a CJS version from the same source, or using a tool that generates the wrapper automatically.
The most robust approach in 2024+ is the ESM-only approach:
Pattern 3: ESM-only (the future)
{
"name": "my-package",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
}
CJS consumers use dynamic import(). This avoids the dual module hazard entirely, simplifies your build, and is the direction the ecosystem is heading. Major packages like chalk, got, execa, p-*, and globby went ESM-only years ago.
The ESM-only approach was controversial when Sindre Sorhus pushed it in 2021, but it's become mainstream. The key insight is that every CommonJS project can use await import() to load ESM — it's inconvenient but it works. Going ESM-only eliminates the dual hazard, reduces package size, enables reliable tree shaking, and simplifies maintenance. The ecosystem has largely caught up.
Complete package.json Example
Here's a production-ready package.json for a dual package:
{
"name": "@myorg/utils",
"version": "2.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
},
"./math": {
"types": "./dist/math.d.ts",
"import": "./dist/math.js",
"require": "./dist/math.cjs",
"default": "./dist/math.js"
},
"./package.json": "./package.json"
},
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist"],
"sideEffects": false
}
Let's break down each field:
Common Build Tools for Dual Packages
You don't have to hand-craft dual builds. These tools handle it:
tsup — the simplest option for TypeScript packages:
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
splitting: false,
clean: true,
});
Vite library mode — for packages that are part of a Vite project:
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
build: {
lib: {
entry: 'src/index.ts',
formats: ['es', 'cjs'],
},
},
});
unbuild — used by the Nuxt ecosystem:
// build.config.ts
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
entries: ['src/index'],
rollup: { emitCJS: true },
declaration: true,
});
| What developers do | What they should do |
|---|---|
| Putting `default` before `import` or `require` in the exports conditions Conditions are checked top to bottom. default matches everything, so anything after it is unreachable. | Order conditions: types first, then import/require, then default last |
| Forgetting to include `./package.json` in exports The exports field encapsulates the package. Some tools need to read package.json directly and will fail without this export. | Add `"./package.json": "./package.json"` to exports |
| Using `module` field without `exports` for ESM entry The `module` field is a bundler convention, not a Node.js standard. Only `exports` with the `import` condition is recognized by Node.js for ESM resolution. | Use the `exports` field with `import` and `require` conditions |
- 1The exports field provides conditional resolution, subpath exports, and package encapsulation
- 2Condition order matters: types first, then import/require, then default last
- 3The type field determines how .js files are interpreted — module for ESM, commonjs for CJS
- 4.mjs is always ESM and .cjs is always CJS, regardless of the type field
- 5For new packages, consider ESM-only — CJS consumers can use dynamic import()
- 6sideEffects: false is critical for tree shaking — always include it if your package has no side effects