Understanding ESM vs CommonJS in Modern Bundlers
Modern frontend architectures rely on deterministic dependency resolution. This guide isolates the semantic divergence between CommonJS (CJS) and ECMAScript Modules (ESM) so you can prevent runtime resolution failures, keep compile-time analysis intact, and enforce strict interop boundaries inside the bundler. The focus stays on module-format semantics, resolver condition matching, and transpilation mechanics. For the underlying graph-traversal and asset-pipeline model, read Core Concepts of Modern Bundling before tuning interop, and treat everything below as the resolution layer that sits on top of it.
The problem exists because the npm ecosystem is a decade-long sediment of two incompatible module systems that must coexist in a single process. CJS was never specified; it is the emergent behavior of Node’s original loader, where require() is a synchronous function call that reads a file, wraps it in a function scope, and returns whatever the module assigned to module.exports. Because that assignment happens at runtime, the shape of a CJS module’s exports is not knowable until the code runs. ESM is the opposite: import and export are static syntax, the export names are fixed before any code executes, and bindings are live views into the exporting module rather than copied values. A bundler has to reconcile these two worlds inside one dependency graph, and every reconciliation is a place where resolution can silently pick the wrong file or fork a module’s state.
What breaks without a correct interop strategy is rarely a clean compile error. The failure surface is subtle: a library resolves to its CJS build in one part of the graph and its ESM build in another, so you get two copies of the same singleton; a named import that the bundler’s lexer failed to detect becomes undefined at runtime instead of at build time; a dev server cold-starts fine but the production Rollup pass emits a wrapper that defeats tree-shaking and quietly doubles a chunk. These are the expensive bugs — they pass CI, ship, and only surface as “Invalid hook call” or a context that resets on every render. This layer sits directly on top of the resolver: after the bundler has walked the graph but before it emits chunks, it decides, per module, which format to treat each file as and which shim to inject. Getting that decision right is the entire job of this page.
Prerequisites
The configuration in this guide assumes Node 18.18+ (20.x recommended for stable require(esm) behavior under the --experimental-require-module flag, default-on from Node 22.12), Vite 5.x or 6.x, Rollup 4.x, and esbuild 0.25.x. The version floor is not arbitrary. Node’s ability to require() a synchronous ESM graph is the single change that removes the majority of dual-package hazards, and it moved from flagged-experimental to default-on across the 20-to-22 line. Pinning below that floor means you are debugging a class of ERR_REQUIRE_ESM failures that the runtime itself has already solved above it. Verify your local toolchain before changing resolver conditions:
# Confirm the toolchain versions this guide targets
node -v # expect v18.18+ (v20.x preferred)
npx vite -v # expect vite/5.x or 6.x
npx rollup -v # expect rollup v4.x
npx esbuild --version # expect 0.25.x
You also need the two static validators that catch malformed exports maps before they reach a consumer:
# Validate a package's publish-time module surface
npm i -g publint @arethetypeswrong/cli
These two tools cover different failure classes and neither substitutes for the other. publint reads the exports map and checks the runtime surface: whether import and require point at files whose actual format matches the condition name, whether the main/module/exports fields agree, and whether a .js file under a "type": "module" package is really ESM. attw (are-the-types-wrong) checks the type surface — whether a consumer importing under import resolves .d.ts files that describe the ESM shape rather than accidentally pointing every condition at a single CJS-flavored declaration, which is the most common reason a package “works at runtime but the types are wrong.” Run both against a packed tarball, not the source tree, because npm pack is what determines the files a consumer actually receives; a correct exports map that references files excluded by .npmignore is a defect only the tarball reveals.
publint and attw validate the module surface before a broken exports map reaches consumers.Core mechanics: how the resolver matches conditions
The architectural shift from CJS to ESM is not merely syntactic; it dictates how toolchains evaluate, cache, and execute code. CJS uses synchronous require() calls and a mutable module.exports object evaluated at runtime. ESM uses static import/export declarations resolved at parse time, with live bindings and native top-level await.
Resolution hinges on the package.json exports field and the active condition set. When a bundler or Node resolves a bare specifier, it walks the exports object top-to-bottom and selects the first key whose condition is active. Order matters: import must precede require, and types must come first for TypeScript to resolve declarations. The default Node condition order is ["node-addons", "node", "import", "require", "default"]; bundlers inject extra conditions such as module, browser, and development.
The mechanism to internalize is that exports is an ordered object, and JavaScript object key order is insertion order. The resolver does not score conditions by specificity or pick the “best” match — it takes the first key it encounters that is present in the active condition set, and stops. This is why types must be the first key: TypeScript adds types to its condition set, but if import appears before types in the object and import is also active, the resolver returns the .js file and never considers the declaration entry. The symptom is a package that runs correctly but reports any for every import, and the cause is purely key order in a file no one thought to check. The same trap applies to default, which by convention is last precisely because it is unconditionally active — placed anywhere but the bottom, it short-circuits every condition below it.
Condition sets are additive and consumer-controlled. Node evaluates with a fixed base set; a bundler layers its own conditions on top, which is why the same package can resolve to different files under node, under Vite’s browser build, and under Vite’s SSR build. browser and node are mutually exclusive by intent — a package uses them to ship a fetch-based implementation to the browser and an http-based one to Node from the same specifier. development and production let a library ship an unminified build with invariant checks to dev and a stripped build to prod; Vite sets one or the other based on the command, so a condition map that omits production and only lists development will fall through to default in a prod build, occasionally shipping the dev variant to users. Because the sets compose, an exports map is only correct relative to the union of every consumer’s conditions, which is why the validators above matter more than local testing.
types after import is silently skipped, exactly as in library mode.// package.json — conditional exports, correct ordering
{
"name": "@scope/widget",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts", // must be first so tsc resolves it
"import": "./dist/index.js", // ESM consumers (Vite, Node import)
"require": "./dist/index.cjs", // CJS consumers (Node require)
"default": "./dist/index.js" // fallback condition, always last
},
"./package.json": "./package.json"
}
}
ESM’s static structure lets bundlers prune unused exports without executing the module, which directly powers tree-shaking and dead-code elimination. CJS, with dynamic require() and reassignable exports, forces conservative inclusion or an aggressive lexer pass. When a bundler wraps a CJS module for an ESM consumer it emits a __toESM(require("...")) shim and an __esModule marker; that wrapper is a black box for export analysis and blocks elimination of unused members.
How it works under the hood
The interop shim exists because the two systems disagree on what “the default export” means. In CJS, module.exports is a single value with no notion of a distinguished default. In ESM, import x from 'pkg' binds x to the module’s default export specifically. To bridge them, transpilers agreed on a marker: a transpiled ESM-to-CJS module sets Object.defineProperty(exports, '__esModule', { value: true }) so that a later consumer can tell “this CJS object was really an ES module” from “this is a hand-written CJS module whose entire value is the default.” The __toESM helper checks that marker at runtime: if __esModule is present it passes the namespace through; if not, it synthesizes a namespace whose default is the whole module.exports object and copies enumerable own keys as named exports. That runtime branch is the reason CJS interop cannot be fully resolved at build time — the helper’s behavior depends on a property that may only exist after the module has executed.
The consequence for tree-shaking is direct. A pruner works by proving that a binding is never read, which requires knowing the complete set of a module’s exports statically. __toESM returns an object assembled at runtime from module.exports, so the pruner cannot enumerate its members and must retain the whole thing. This is why one CJS dependency deep in an otherwise-ESM graph can pin kilobytes of dead code that would otherwise be dropped: the wrapper is an opaque boundary, and everything reachable through it is retained conservatively. The cjs-module-lexer that Node and Rollup use mitigates this by scanning CJS source for common export patterns (exports.foo = ..., module.exports = { foo }, re-exports via Object.defineProperty) so named imports can be bound statically — but it is a heuristic lexer, not an evaluator, so any export produced by a computed key or a loop is invisible to it, which is the root of the named-export-not-found failure covered below.
The imports field (subpath imports, distinct from exports) lets a package remap internal specifiers per condition — useful for swapping a Node implementation for a browser one without touching call sites:
// package.json — internal subpath imports keyed by condition
{
"imports": {
"#crypto": {
"node": "./src/crypto.node.js",
"browser": "./src/crypto.browser.js",
"default": "./src/crypto.browser.js"
}
}
}
Subpath imports must begin with # — that prefix is what tells the resolver a specifier is package-internal and should be looked up in imports rather than treated as a bare dependency or a relative path. The advantage over a build-time alias plugin is that imports is resolved by Node and every bundler natively, with no plugin, so the same source runs correctly under node --test, under Vite, and under a downstream consumer that bundles your package from source. The failure mode to watch for is a missing default branch: if a consumer’s active condition set matches none of the listed keys, resolution throws ERR_PACKAGE_IMPORT_NOT_DEFINED, so a default entry is effectively mandatory unless you can prove every consumer supplies one of the named conditions. Keep imports for genuine platform splits; using it as a general path-alias mechanism couples your internal layout to the resolver and tends to surprise anyone reading the call site.
Configuration & CLI reference
Each toolchain resolves format differently. Vite delegates to esbuild for dependency pre-bundling and to Rollup for production builds, so interop requires explicit resolver overrides on both sides. The blocks below are complete and runnable.
The reason a single Vite override is rarely enough is that dev and build are two different engines with two different resolvers, and a dependency can behave differently under each. In dev, esbuild pre-bundles your node_modules into a small number of ESM files, converting any CJS it finds along the way; the conversion is aggressive and forgiving. In build, Rollup with @rollup/plugin-commonjs performs a stricter, statically-analyzed conversion that can reject patterns esbuild waved through. The practical consequence is the “works in dev, breaks in build” report: a CJS dependency whose exports esbuild’s tolerant lexer detected but Rollup’s did not. Configuring interop means keeping both engines’ views of a dependency aligned, which is why the settings below always come in pairs.
Vite — conditions, pre-bundling, SSR externalization
Vite — conditions, pre-bundling, SSR externalization
// vite.config.ts — Vite 5.x / 6.x
import { defineConfig } from 'vite';
export default defineConfig({
resolve: {
// Prioritize ESM entry points over CJS fallbacks during resolution
conditions: ['module', 'browser', 'development|production', 'default'],
},
optimizeDeps: {
// Force esbuild to convert these CJS deps to ESM at dev cold-start
include: ['cjs-heavy-lib', 'another-cjs-dep'],
// Leave pure-ESM packages alone (no pre-bundle indirection)
exclude: ['esm-native-lib'],
},
ssr: {
// Bundle CJS deps for SSR instead of externalizing them to require()
noExternal: ['node-cjs-dep'],
},
build: {
rollupOptions: {
output: {
// 'auto' emits synthetic default wrappers for CJS; 'compat' is stricter
interop: 'auto',
},
},
},
});
Read resolve.conditions top-to-bottom the same way the resolver reads exports: it is the priority list Vite hands to the resolver, and putting module before browser before default is what makes an ESM entry win over a CJS main when a package offers both. The optimizeDeps split is the highest-leverage knob. include forces esbuild to pre-bundle a dependency even when Vite would not have discovered it through static import scanning — necessary for deps loaded dynamically or reached only through a CJS require chain, because an un-pre-bundled CJS file served raw to the browser throws “Cannot use import statement outside a module” on its first ESM-looking line. exclude does the reverse for a dependency that is already clean ESM: pre-bundling it adds a layer of __toESM indirection and an extra file hop for nothing, so excluding it keeps the module identity intact and avoids a needless conversion. ssr.noExternal is a third axis entirely — it governs whether Vite bundles a dependency into the SSR output or leaves it as a bare require/import for the Node runtime to resolve, and it is the correct fix when an ESM-only dependency crashes an SSR build with ERR_REQUIRE_ESM. For the full interop decision tree in Vite, the dedicated ESM/CJS interop in Vite guide walks each optimizeDeps / ssr.noExternal knob.
Rollup — the CommonJS plugin
// rollup.config.js — Rollup 4.x
import commonjs from '@rollup/plugin-commonjs';
import { nodeResolve } from '@rollup/plugin-node-resolve';
export default {
input: 'src/index.ts',
output: { dir: 'dist', format: 'es', interop: 'auto' },
plugins: [
nodeResolve({ exportConditions: ['module', 'import', 'default'] }),
commonjs({
// 'auto' returns the default export only when there is no __esModule marker
requireReturnsDefault: 'auto',
// Let CJS files that already contain import/export be parsed as mixed
transformMixedEsModules: true,
}),
],
};
Plugin order is load-bearing here: nodeResolve must run before commonjs so that bare specifiers are resolved to on-disk paths before the CommonJS transform decides how to wrap them. The exportConditions array passed to nodeResolve is Rollup’s equivalent of Vite’s resolve.conditions — it is the condition set the resolver matches against, and omitting module/import there is a common reason Rollup picks a package’s CJS main when an ESM build was available. requireReturnsDefault is the setting that most often decides whether a converted require() yields the module’s namespace or just its default. The 'auto' value inspects the target for an __esModule marker and returns the default only when the marker is absent, which is correct for the mixed ecosystem; the alternatives (false, true, 'preferred') exist for dependencies that lie about their marker, and reaching for them is a signal that the dependency’s own exports map is the real defect. transformMixedEsModules covers the increasingly common file that has been partially migrated — it contains both require() and import — which the plugin would otherwise refuse to treat as CJS at all.
esbuild — format and platform flags
# esbuild 0.25.x, Node 20+ — bundle to ESM, keep native addons external
esbuild src/index.ts --bundle --format=esm --platform=node \
--external:*.node --tree-shaking=true --metafile=build/meta.json
The two flags that determine interop behavior are --format and --platform, and they interact. --platform=node sets the default condition set to include node and switches the main field precedence toward CommonJS-friendly resolution; --platform=browser (the default) prefers browser and module. --format=esm forces the output to ES module syntax regardless of input, which is what triggers the __toESM wrapping for any CJS input in the graph. Native addons — files loaded through require('./foo.node') — cannot be bundled because they are compiled binaries, so --external:*.node marks them for runtime resolution; leaving that out produces a build that fails at bundle time with an unrecognized-file error. --metafile=build/meta.json is not optional in practice: it is the only way to see, after the fact, which inputs esbuild classified as CJS and which it left as ESM, and it is the input the performance-measurement step below depends on.
An esbuild subtlety that bites SSR and CLI builds specifically: esbuild does not read package.json type to decide the output extension, so a bundle emitted as ESM into a .js file inside a package without "type": "module" will be re-parsed by Node as CJS and throw at the first import. When bundling to ESM for Node, emit .mjs or set the output package’s type to module — the extension and the type field, not the --format flag, are what Node consults at load time.
Numbered workflow: diagnose and pin an interop boundary
-
Reproduce against a clean cache. Delete
node_modules/.viteand runnpx vite dev --forceso a stale pre-bundle cannot mask the real resolution. Vite fingerprints its pre-bundle by a hash of lockfile, config, and a handful of env inputs, and it will happily serve a cacheddeps/directory that predates the change you are debugging — so the first symptom to rule out is that you are looking at a build from before the problem existed.--forceinvalidates that cache; deleting the directory is the belt-and-braces version for when the fingerprint failed to notice a transitive change. Confirm the reproduction is real by watching the terminal for the “Pre-bundling dependencies” line, which only prints on a cold optimize. -
Trace the resolver. Run
NODE_DEBUG=module node server.js(ornpx vite dev --debug) and read whichexportscondition was selected for the failing package. The value of tracing rather than guessing is that the resolver’s choice is often counterintuitive — a package you assumed was ESM may be resolving throughrequirebecause your condition list putrequirebeforeimport, or because the package’s ownexportslists them in that order. The debug output names the exact file the resolver landed on; compare that path against the package’sdist/layout to see whether you got the.js(ESM) or.cjs(CJS) build. If the selected file is not the one you expected, the fix is a condition-order change, not an interop shim. -
Inspect the emitted shim. Grep the dev pre-bundle for the wrapper:
grep -rl "__toESM(require" node_modules/.vite/deps/. A match means the dep is CJS and was converted. This step distinguishes the two root causes that produce identical symptoms: a genuinely-CJS dependency that was wrapped, versus an ESM dependency that resolved to a stale or wrong file. If the grep returns the failing dependency’s chunk, the dependency is CJS and your job is to make the conversion correct; if it does not, the problem is upstream in resolution and step 2 is where the answer lives. Reading the wrapped chunk directly also shows you which named exports the lexer managed to hoist, which previews whether step 6 will find them. -
Validate the package surface. Run
npx publintandnpx @arethetypeswrong/cli --packagainst the dependency (or your own package) to confirmimport/require/typesordering and detect a masked dual entry. When the failing package is a third-party dependency you do not control, this step converts “my build is broken” into a specific, reportable defect: publint will name the exactexportskey that points at the wrong format, and that output is what belongs in the upstream issue. When the failing package is your own, treat any publint error as a release blocker — a malformedexportsmap that ships is a defect every downstream consumer inherits and none of them can fix. -
Pin the condition. Add the dep to
optimizeDeps.include(dev) and, for SSR,ssr.noExternal; setbuild.rollupOptions.output.interop: 'auto'for production. This is the point where you stop diagnosing and commit a fix that survives the dev/build engine split described earlier —optimizeDeps.includehandles esbuild’s view,output.interophandles Rollup’s, andssr.noExternalhandles the Node runtime’s. Applying only one of the three is the most common way a fix “works locally but breaks in CI,” because CI usually runs the build path, not the dev path you tested against. -
Verify. Re-run the build and confirm the error is gone and that
import metaanalysis still reports the dep once, not twice. The “once, not twice” check is the one that rules out the dual-package hazard, and it is easy to skip because the original error being gone feels like success. Read the--metafileoutput or the visualizer and search for the dependency’s name: two entries, one under an ESM path and one under a CJS path, means you silenced the symptom while forking the module’s state — a strictly worse outcome than the original crash because it now fails at runtime instead of build time.
Debugging & failure modes
SyntaxError: Cannot use import statement outside a module
A CJS file (or a file in a package without "type": "module") is being evaluated as ESM. Add the package to optimizeDeps.include so esbuild rewrites it, or correct the package’s exports/type fields. The symptom is precise about location: the error names a file and a line, and that line is almost always the first import statement in a file the loader decided was CJS. The root cause is a mismatch between the file’s contents (ESM syntax) and the loader’s classification (CJS, because the nearest package.json has no "type": "module" or the exports condition routed here through require). The fix depends on ownership — for a dependency, pre-bundling through optimizeDeps.include lets esbuild transpile the syntax away; for your own code, the correct fix is the type field or the file extension, because pre-bundling your own source masks a defect you should be fixing at the root. Confirm the fix by deleting the pre-bundle cache and reloading: the error should not return on a cold start.
ERR_REQUIRE_ESM
A CJS module called require() on an ESM-only dependency in Node. Either upgrade the consumer to ESM, use dynamic await import(), or — on Node 22.12+ — rely on the default-on synchronous require(esm) support. For SSR, add the dep to ssr.noExternal so Vite bundles it rather than handing it to Node’s require. The distinguishing detail of this error versus the syntax error above is that it originates in Node’s loader, not the bundler — the stack trace bottoms out in an internal module such as node:internal/modules/cjs/loader. That tells you the failing require survived bundling and reached the runtime, which is only possible for an externalized dependency; a bundled one would have been converted. This is why ssr.noExternal is the SSR fix: it moves the dependency from “externalized, resolved by Node’s require” to “bundled, converted by Vite.” Confirm by checking that the dependency no longer appears in the externalized set — in an SSR build, grep the server output for a bare require('the-dep'); its absence means the dependency was inlined.
The dual-package hazard
When both the import and require conditions resolve in the same process, the package loads twice and forks its module state. Symptoms: instanceof checks fail across the boundary, React throws “Invalid hook call” from a duplicated copy, or a context singleton is silently doubled. Mitigation: publish a single implementation (ESM) and make the CJS entry a thin module.exports = require('./esm wrapper') re-export, or deduplicate via resolve.dedupe in Vite. The reason this hazard is the most dangerous entry in this list is that it produces no build error at all — both copies load successfully, so nothing fails until two pieces of code that assume a shared singleton meet at runtime. The instanceof failure is the clearest tell: an object created by copy A fails an instanceof check against copy B’s class, because the two classes are distinct objects despite identical source. For a library author, the durable fix is to never ship two full implementations — one real build plus a re-export means there is only one copy of the module state to fork. For a consumer stuck with a dual-published dependency, resolve.dedupe: ['the-dep'] forces every import of that name to the same physical file. Confirm with the step-6 check above: the dependency must appear exactly once in the metafile.
default is not exported by ... / named export not found
Rollup or cjs-module-lexer failed to statically detect a CJS export. Set output.interop: 'auto' and import the default then destructure. The full repro and fix live in resolving named-export-not-found errors. The mechanism is the lexer limitation described under “How it works under the hood”: cjs-module-lexer recognizes a fixed vocabulary of export patterns, and a CJS module that assigns its exports through a computed key, a loop, or a helper function produces exports the lexer cannot see. Rollup then rejects import { thing } from 'pkg' at build time because, as far as static analysis knows, thing is not exported. The workaround imports the default (the whole module.exports object, which does exist) and reads the member off it at runtime:
// consumer.js — bypass the lexer gap for a dynamically-built CJS export
// Named import fails: the lexer never saw `thing` on module.exports.
import pkg from 'legacy-cjs-lib'; // default = the whole module.exports
const { thing } = pkg; // read the member at runtime instead
export { thing };
Confirm the fix resolves the value rather than papering over it: log typeof thing after the destructure. If it is undefined, the member genuinely does not exist and the problem is a wrong import name, not the lexer gap — the two failures share an error message but have opposite fixes.
Performance impact & measurement
Migrating CJS-heavy dependency trees toward pure ESM typically yields a 15–30% reduction in production bundle size and a 20–40% drop in V8 parse/compile time, because each __toESM wrapper adds roughly 1.2 KB per module and disables member-level pruning. Proper pre-bundling of problem CJS deps cuts Vite dev cold-start by 35–50% and removes ERR_REQUIRE_ESM crashes during HMR. Measure it with the esbuild --metafile output (feed build/meta.json to a visualizer) and with vite build followed by rollup-plugin-visualizer to confirm no CJS wrapper survives in a hot path.
Treat these ranges as symptoms to investigate, not guarantees to expect — the size win is proportional to how much dead code the wrapper was pinning, so a CJS dependency you use fully sheds almost nothing, while one you import a single function from can shed most of its weight once the wrapper is gone and member pruning is re-enabled. The parse/compile win is a second-order effect of the same mechanism: less shipped code is less code for V8 to parse, and eliminating the wrapper’s runtime __esModule branch removes a small deopt-prone path from the hot module. The measurement discipline that matters is to diff the metafile before and after, not to trust the visualizer’s totals in isolation. The metafile records, per input, the byte count and the imports; searching it for __toESM or for a dependency appearing under two paths is how you prove a specific wrapper was removed rather than merely observing that the bundle got smaller for some unrelated reason.
When not to dual-publish
Shipping both an ESM and a CJS build is the reflex, but it is the wrong default for most libraries. Every dual-published package is a standing dual-package-hazard risk, doubles the surface publint and attw must validate, and doubles the build matrix. Publish CJS only when you have concrete evidence of a consumer that cannot load ESM — an older Node service pinned below the require(esm) floor, or a build tool with no ESM path. For a library whose consumers are exclusively bundlers and Node 22.12+, ship ESM alone; the require(esm) support means even require()-based consumers can load it, and the single build cannot fork its own state. When you must ship both, make one of them authoritative and the other a thin re-export, so there is exactly one copy of every stateful value regardless of which condition a consumer resolves.
Compatibility matrix
require(esm) removes most of the hazard for sync graphs.| Consumer / loader | Pure ESM dep | Pure CJS dep | Dual-package dep | Required override |
|---|---|---|---|---|
| Vite dev (esbuild pre-bundle) | native | converted via optimizeDeps |
risk of double-load | optimizeDeps.include, resolve.dedupe |
| Vite build (Rollup) | native | @rollup/plugin-commonjs |
interop: 'auto' |
output.interop |
| Vite SSR (Node) | native | externalized to require |
hazard if mixed | ssr.noExternal |
Node 18.x import |
native | __esModule interop |
import/require split |
correct exports order |
Node 22.12+ require(esm) |
default-on | native | reduced hazard | none for sync graphs |
esbuild --format=esm |
native | auto-detected, wrapped | wrapper per condition | --external:*.node |
The matrix is organized by consumer because the correct override is a property of who is loading the dependency, not of the dependency itself. The same dual-published package needs optimizeDeps.include under Vite dev, output.interop under Vite build, and ssr.noExternal under Vite SSR — three different knobs for one package, because three different resolvers are doing the loading. The one row that changes the calculus is Node 22.12+: with require(esm) default-on, a synchronous ESM graph no longer forces a consumer into the import/require split that creates the hazard, so a library targeting only that floor can drop its CJS build and delete an entire column of overrides from every downstream consumer’s config. That is the direction the ecosystem is moving, and it is why the “ship ESM only” advice above is safe for an increasing share of packages.
CI integration
The failure modes in this guide are cheap to catch in CI and expensive to catch in production, so gate on them. Run the two validators against a packed tarball on every release, and fail the job on any error rather than treating their output as advisory. The check below is the minimum that would have caught every symptom on this page — a malformed exports map, a wrong condition order, or a type surface that resolves to the wrong declarations:
# ci/validate-package.sh — run in the release job, before publish
# Tools: publint + @arethetypeswrong/cli (installed as devDependencies)
set -euo pipefail
npm run build # produce dist/ the exports map points at
npm pack --silent # emit the exact tarball a consumer receives
npx publint --strict # fail on any exports/format mismatch
npx @arethetypeswrong/cli --pack # fail on wrong .d.ts resolution per condition
Two habits make the gate reliable. Build before you validate, because both tools inspect the files exports references and an empty or stale dist/ produces false passes. And validate the tarball (npm pack) rather than the working tree, because .npmignore and the files field decide what actually ships — a correct exports map that points at files excluded from the tarball passes a source-tree check and fails for every real consumer. For an application rather than a library, the equivalent CI gate is the step-6 verification: assert that no dependency appears twice in the build metafile, which turns the dual-package hazard from a runtime surprise into a failed build.
Related
- Core Concepts of Modern Bundling — the resolution and graph model this interop layer sits on top of.
- How to configure ESM and CJS interop in Vite — exact
optimizeDeps,ssr.noExternal, and Rollupinteropsettings. - Resolving “named export not found” errors — repro and fix for the
cjs-module-lexerdetection gap. - Tree-Shaking Mechanics and Dead Code Elimination — why CJS wrappers block static pruning.