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/cjs

tsconfig.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."
    fi

Performance validation checklist

  1. Parse/compile overhead: Use PerformanceObserver with entryType: 'resource' to measure script evaluation time. 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.
  4. Cache efficiency: Granular ESM chunks improve HTTP/2 multiplexing and long-term cache hit rates. Align Cache-Control: immutable headers 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.