Converting CJS Libraries to ESM for Better Bundling
Modern frontend architectures rely on static analysis to optimize delivery payloads, yet legacy CommonJS (CJS) modules introduce dynamic require() calls that break deterministic dependency graphs. This guide details the architectural workflow for migrating libraries to ECMAScript Modules (ESM), enabling bundlers to perform precise dead code elimination and scope hoisting. For a foundational understanding of how module formats impact dependency resolution, review the broader principles in Advanced Tree-Shaking & Dependency Optimization.
Architectural Pattern: Dual-Package Conditional Exports
The industry-standard migration strategy uses the package.json exports field to map require to CJS artifacts and import to ESM artifacts. This conditional resolution prevents bundlers from falling back to legacy main/module fields that trigger opaque module wrapping. The workflow requires compiling source code to both formats, generating .cjs and .mjs outputs, and declaring sideEffects to signal pure modules. Proper configuration here directly impacts how downstream consumers prune unused exports, as detailed in Configuring sideEffects for Optimal Tree-Shaking.
{
"name": "@org/ui-kit",
"type": "module",
"exports": {
".": {
"import": { "types": "./dist/esm/index.d.mts", "default": "./dist/esm/index.mjs" },
"require": { "types": "./dist/cjs/index.d.cts", "default": "./dist/cjs/index.cjs" }
},
"./components/*": {
"import": "./dist/esm/components/*.mjs",
"require": "./dist/cjs/components/*.cjs"
}
},
"sideEffects": false,
"scripts": {
"build": "npm run build:esm && npm run build:cjs"
}
}Compile using TypeScript with separate tsconfig files:
tsc --project tsconfig.esm.json --outDir dist/esm
tsc --project tsconfig.cjs.json --outDir dist/cjstsconfig.esm.json should set "module": "NodeNext" and "moduleResolution": "NodeNext". The CJS config uses "module": "CommonJS". Alternatively, a bundler like Rollup or tsup can emit both formats from a single build pass with less configuration duplication.
Chunk Graph Behavior & Static Analysis Mechanics
Webpack 5 and Vite 5+ (via Rollup) construct chunk graphs by tracing static import declarations at build time. ESM’s lexical scoping and immutable bindings allow the bundler to flatten module boundaries through scope hoisting, whereas CJS’s mutable module.exports forces runtime wrapper generation. When a library remains CJS-only, the bundler treats it as a black box, disabling cross-module optimization and forcing full inclusion.
Transitioning to ESM unlocks:
- Module concatenation: Inlining of small modules into parent chunks, reducing closure overhead.
- Export-level pruning: Removal of unused named exports without dropping the entire file.
- Deterministic hashing: Stable chunk filenames due to predictable dependency ordering.
This is further explored in Eliminating Dead Code with Modern Build Tools.
Tooling Configuration: Webpack 5 & Vite 5+ Workflows
Implementation requires explicit resolver tuning to prioritize ESM entry points. In Webpack 5, configure resolve.conditionNames to include 'import' (which selects the ESM branch of a package’s exports map) and enable optimization.usedExports alongside optimization.sideEffects. For Vite, build.commonjsOptions.transformMixedEsModules: true handles hybrid packages that mix ESM and CJS syntax within a single file.
If interop fails due to legacy __esModule flags or dynamic require hoisting, see Fixing tree-shaking failures with Webpack 5 for targeted diagnostic steps.
Webpack 5 configuration:
// webpack.config.js
module.exports = {
resolve: {
conditionNames: ['import', 'module', 'require'],
mainFields: ['module', 'main']
},
optimization: {
usedExports: true,
sideEffects: true,
concatenateModules: true,
splitChunks: {
chunks: 'all',
minSize: 20000
}
},
module: {
rules: [
{
test: /\.m?js$/,
resolve: { fullySpecified: false },
type: 'javascript/auto'
}
]
}
};Vite 5 configuration:
// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
optimizeDeps: {
include: ['@org/ui-kit'],
esbuildOptions: {
target: 'es2022'
}
},
build: {
commonjsOptions: {
transformMixedEsModules: true
},
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules/@org/ui-kit')) return 'ui-kit';
}
}
}
}
});Measurable Trade-offs & Performance Validation
The migration yields quantifiable gains: typical bundle size reductions of 15–40%, decreased JavaScript parse/compile time (often 20–35 ms per 100 KB), and improved TTI due to smaller initial chunks. Trade-offs include increased CI/CD pipeline complexity for dual-publishing, stricter TypeScript moduleResolution requirements, and potential hydration mismatches in SSR frameworks if ESM/CJS interop is misaligned.
Validation requires running webpack-bundle-analyzer or rollup-plugin-visualizer pre- and post-migration, tracking module inclusion counts, and verifying chunk graph integrity across production builds.
CI validation (GitHub Actions):
- name: Validate Bundle Size
run: |
npm run build
npx size-limit
# Check for CJS fallbacks in the output
if grep -rq '__esModule' dist/; then
echo "::warning::__esModule interop wrappers detected. Verify exports mapping."
fiPerformance validation checklist
- Parse/compile overhead: Use
PerformanceObserverwithentryType: 'resource'to measure script evaluation time. ESM chunks typically show 18–22% faster V8 compilation due to reduced AST complexity. - Tree-shaking integrity: Verify that
optimization.usedExportscorrectly marks unused exports asunused harmony exportin Webpack stats. - SSR hydration safety: Ensure framework-specific loaders (Next.js, Remix, SvelteKit) resolve the
importcondition during client-side hydration. Misconfiguredexportsmaps cause duplicate module instantiation. - Cache efficiency: Granular ESM chunks improve HTTP/2 multiplexing and long-term cache hit rates. Align
Cache-Control: immutableheaders with content hashes.
Migrating to ESM is an architectural prerequisite for modern bundler optimization, not merely a syntax update. Enforce strict exports mapping, validate resolver behavior in CI, and monitor chunk graph metrics to sustain delivery performance at scale.