Bundling and publishing: ESM, CJS, and exports
Publishing is package.json design. The exports map is the modern entry point: it picks ESM vs CJS vs types by condition and encapsulates internals. Dual-publish risks the dual-package hazard; ESM-only is often simpler.
A library team shipped 2.0 to fix a memory leak. Within an hour a major consumer’s CI went red: their bundler had been deep-importing their-lib/dist/internal/cache.js to monkey-patch a class, and the refactor moved that file. The library authors had never published an "exports" map, so every file on disk was a public API by accident — and a “private” rename became a breaking change for thousands of installs. The fix was one field in package.json. The lesson: what you ship is not your source tree, it’s the surface your package.json declares, and if you don’t declare it, your whole dist/ is the contract.
Two module formats, and how Node chooses
Node runs your code as one of two formats, and the choice is made before any import resolves. A package is ESM if its package.json has "type": "module" (or the file ends in .mjs); otherwise it is CommonJS (or .cjs). This single field changes everything downstream: ESM uses static import/export and top-level await; CJS uses require/module.exports. You cannot require() an ESM-only package from CJS without await import(), and ESM consumers can import a CJS package but only get its default export cleanly.
The hard problem is publishing for both kinds of consumer — a dual package. The tempting answer is to ship a dist/esm/ and a dist/cjs/ build of the same code. It works until it doesn’t: a consumer whose dependency graph pulls your CJS copy on one path and your ESM copy on another now has two distinct module instances loaded at once. Two copies means two class identities, so instanceof fails across the boundary, and any module-level state (a cache, a registry, a singleton) is silently duplicated. This is the dual-package hazard, and it is genuinely hard to fully avoid. For most new libraries the pragmatic answer is ship ESM-only — Node has supported ESM in production for years — or use a build tool that emits both and keep all shared identity/state in a single internal CJS core that both wrappers re-export.
# ESM-only is the simplest contract: one format, no hazard
# package.json
{
"type": "module",
"exports": "./dist/index.js"
}The exports map: entry points and encapsulation
If two packages share a boundary and one can reach arbitrarily deep into the other’s internals, you lose the ability to refactor without breaking your consumers — and the fix is not tests, it’s the exports map.
The "exports" field is the modern entry point and supersedes "main". It does two jobs at once. First, conditional resolution: you map the same specifier to different files depending on how it’s loaded — "import" for ESM, "require" for CJS, "types" for TypeScript, and "default" as the fallback. Order matters; Node reads conditions top-to-bottom and takes the first match, so "types" and the most specific conditions go first, "default" last. Second, subpath exports: "./feature" exposes a deep entry without leaking your folder layout.
The part seniors care about most is encapsulation. Once you define "exports", only the listed subpaths are importable. A consumer can no longer reach your-pkg/dist/internal/secret.js — that import throws ERR_PACKAGE_PATH_NOT_EXPORTED. This is the field that would have saved the hook team: with an exports map, the internal file was never part of the contract, so moving it is free. Keep "main" as a fallback for ancient tooling that ignores exports, and always provide a "types" condition so TypeScript consumers get real types instead of any.
{
"name": "their-lib",
"type": "module",
"main": "./dist/index.cjs", // fallback for old tools
"exports": {
".": {
"types": "./dist/index.d.ts", // first: TS reads this
"import": "./dist/index.js", // ESM consumers
"require": "./dist/index.cjs", // CJS consumers
"default": "./dist/index.js" // last-resort fallback
},
"./feature": "./dist/feature.js" // explicit subpath; nothing else is reachable
}
}| package.json field | What it controls | Skip it and… |
|---|---|---|
type | ESM (module) vs CJS for .js files | defaults to CJS; ESM syntax errors |
exports | entry points + conditions + encapsulation | whole dist/ is a public API |
main | legacy entry; fallback when exports ignored | old tools can’t resolve you |
types | TS declarations (also a condition) | TS consumers get any |
bin | maps a CLI command to a file | no installed command |
files | allowlist of what gets packed | you ship tests, src, secrets |
sideEffects | tells bundlers modules are pure | weaker tree-shaking |
engines | declares supported Node range | silent breakage on old Node |
Together these fields form a layered contract: type and exports define the module surface, files and sideEffects shape what lands in the tarball and how bundlers treat it, and bin/engines cover CLI and runtime compatibility. Skip any one of them and you ship a contract with an invisible hole — the kind that surfaces in someone else’s CI three months later.
▸Why this works
Why does adding "exports" break existing consumers? Because encapsulation is retroactive and total. Before exports, every file in your package was reachable; the day you add the field, every deep import that isn’t explicitly listed starts throwing ERR_PACKAGE_PATH_NOT_EXPORTED. This is intentional — it’s the whole point — but it means adding exports to a mature package is a semver-major change. List every subpath your users legitimately rely on ("./package.json" is a common one tools expect), ship it as a major, and document the deep-import paths you’re retiring.
Tree-shaking, the CLI bin, and publish hygiene
ESM’s static structure is what lets a bundler tree-shake — analyze imports at build time and drop code no one reaches. CJS resists this: require is a dynamic function call, so a bundler usually can’t prove a branch is dead. You help the bundler with "sideEffects": false, a promise that importing your modules does nothing but define exports (no global mutation, no polyfill registration). With that flag, an unused export of yours can be pruned entirely; without it the bundler must keep modules around in case the import itself mattered.
A CLI is just a "bin" mapping from command name to a file. Three things must line up or the command silently fails to run: the target file needs a shebang #!/usr/bin/env node as its first line, it needs execute permission (chmod +x), and on install npm creates the symlink into node_modules/.bin for you. Forget the shebang and the shell tries to run JS as a shell script; forget the permission bit and you get “permission denied.”
{
"bin": { "their-cli": "./dist/cli.js" },
"files": ["dist"],
"engines": { "node": ">=18.17" },
"sideEffects": false
}Finally, ship the build, not the source. A bundler like tsup/esbuild/rollup emits dist/ plus .d.ts declarations; use the "files" allowlist (or .npmignore) so the tarball contains only dist — not your src/, tests, or .env. Run npm pack --dry-run to see the exact file list before you publish. Bump versions by semver (a removed export or new exports map is major). And publish with npm publish --provenance from CI so npm records a signed attestation linking the artifact to the commit and workflow that built it — a supply-chain defense against tampered or typosquatted releases.
You're publishing a new general-purpose library in 2026 and must choose a distribution format. What's the senior default?
What does adding an exports map buy you beyond picking ESM vs CJS files?
A consumer reports that instanceof MyError returns false even for an error your dual-published library clearly threw. What's the most likely cause?
Order the steps to publish a library + CLI safely, from build to attested release:
- 1 Build with tsup/esbuild → emit dist/ JS plus .d.ts declarations
- 2 Add the types condition so TS consumers resolve real declarations, not any
- 3 Write the exports map (entry + subpaths) so only intended paths are importable
- 4 Set files: ["dist"] (and bin + shebang + chmod +x for the CLI) so the tarball is minimal and the command runs
- 5 Publish from CI with npm publish --provenance to attest the artifact
- 01Why is adding an exports map to a mature package a breaking (semver-major) change, and how do you ship it safely?
- 02What exactly is the dual-package hazard, and what's the simplest way to avoid it?
Publishing a package is really designing its package.json: that file, not your source tree, defines the public surface. Node picks a module format from "type" — "module" for ESM, otherwise CommonJS — and the two don’t mix freely, so serving both consumers (a dual package) risks the dual-package hazard, where two loaded copies produce two class identities (instanceof breaks) and duplicated module state; for new libraries shipping ESM-only sidesteps it entirely. The "exports" map is the modern entry point that supersedes "main": it resolves by condition ("types" first, then "import"/"require", "default" last) and, crucially, encapsulates — only listed subpaths are importable, so unlisted internals can be refactored freely (which is why adding exports to an existing package is a semver-major break). ESM plus "sideEffects": false unlocks real tree-shaking; a CLI needs "bin" plus a #!/usr/bin/env node shebang and execute permission; and ship hygiene means emitting a build with .d.ts, a "files" allowlist, an "engines" range, semver bumps, and npm publish --provenance from CI for a signed supply-chain attestation. Now when you see a consumer bug traced to a deep import — or an instanceof failure across a package boundary — you’ll know exactly which fields were missing from the package.json and why.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.