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 utilizes 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 twice (or using a single AST transform targeting both formats), generating .cjs and .mjs outputs, and aligning the sideEffects flag to signal pure modules. Proper configuration here directly impacts how downstream consumers prune unused exports, as detailed in Configuring sideEffects for Optimal Tree-Shaking.

Production package.json configuration:

{
 "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 or SWC with strict target isolation:

tsc --project tsconfig.esm.json --outDir dist/esm
tsc --project tsconfig.cjs.json --outDir dist/cjs

Ensure tsconfig.esm.json sets "module": "NodeNext" and "moduleResolution": "NodeNext", while the CJS config uses "module": "CommonJS".

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 granular chunk splitting, where unused utilities are dropped entirely during the minification phase, a process further explored in Eliminating Dead Code with Modern Build Tools.

Static analysis operates at the AST level. ESM exports are resolved during the parsing phase, allowing the bundler to build a directed acyclic graph (DAG) of dependencies. CJS require() calls, however, can be conditional, dynamic, or nested inside control flow, forcing the resolver to assume worst-case inclusion. By migrating to ESM, you enable:

  • 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.

Tooling Configuration: Webpack 5 & Vite 5+ Workflows

Implementation requires explicit resolver tuning to prioritize ESM entry points. In Webpack 5, configure resolve.mainFields: ['module', 'main'] and enable optimization.usedExports: true alongside optimization.sideEffects: true. For Vite 5, leverage optimizeDeps.include to pre-bundle dependencies and set build.commonjsOptions.transformMixedEsModules: true when handling hybrid packages. If interop fails due to legacy __esModule flags or dynamic require hoisting, apply targeted plugin overrides or adjust module.rules.type: 'javascript/auto' to bypass automatic CJS wrapping. Diagnostic steps for resolving graph fragmentation are covered in Fixing tree-shaking failures with Webpack 5.

Webpack 5 Configuration:

module.exports = {
  resolve: {
  mainFields: ['module', 'main'],
  conditionNames: ['import', 'module', 'require']
  },
  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:

import { defineConfig } from 'vite';

export default defineConfig({
  optimizeDeps: {
  include: ['@org/ui-kit'],
  esbuildOptions: {
  target: 'es2022'
  }
  },
  build: {
  commonjsOptions: {
  transformMixedEsModules: true,
  strictRequires: 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–35ms per 100KB), and improved Time to Interactive (TTI) due to smaller initial chunks. However, these benefits incur trade-offs, including 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 vite-bundle-visualizer pre- and post-migration, tracking module inclusion counts, and verifying chunk graph integrity across production builds.

CI Gating Example (GitHub Actions):

- name: Validate Bundle Size & ESM Fallback
 run: |
 npm run build
 ANALYSIS=$(npx webpack-bundle-analyzer dist/stats.json --mode json)
 TOTAL_SIZE=$(echo $ANALYSIS | jq '.size')
 CJS_FALLBACK=$(grep -c "require(" dist/stats.json || true)
 
 if [ "$TOTAL_SIZE" -gt 250000 ]; then
 echo "::error::Bundle exceeds 250KB threshold. Current: ${TOTAL_SIZE}B"
 exit 1
 fi
 
 if [ "$CJS_FALLBACK" -gt 0 ]; then
 echo "::error::CJS fallback detected in production graph. Verify exports mapping."
 exit 1
 fi
 echo "✅ Bundle validation passed: ${TOTAL_SIZE}B, ESM-only graph confirmed."

Performance Validation Checklist:

  1. Parse/Compile Overhead: Measure PerformanceObserver initiatorType for script execution. ESM chunks typically show 18–22% faster V8 compilation due to reduced AST complexity.
  2. Tree-Shaking Integrity: Verify that optimization.usedExports correctly marks unused exports as unused harmony export in Webpack stats.
  3. SSR Hydration Safety: Ensure framework-specific loaders (Next.js, Remix, SvelteKit) resolve the import condition during client-side hydration. Misconfigured exports maps cause duplicate module instantiation, triggering hydration warnings.
  4. Cache Efficiency: Granular ESM chunks improve HTTP/2 multiplexing and long-term cache hit rates. Track Cache-Control: immutable alignment with content hashes.

Migrating to ESM is not merely a syntax update; it is an architectural prerequisite for modern bundler optimization. Enforce strict exports mapping, validate resolver behavior in CI, and monitor chunk graph metrics to sustain delivery performance at scale.