Converting CJS Libraries to ESM for Better Bundling

When a dependency ships only CommonJS, your bundler cannot perform export-level dead code elimination on it. Every require() call is a runtime operation that the bundler cannot statically analyse at build time, so the entire module is copied into the output chunk — unused exports, internal helpers, and all. For a mid-size UI component library that exposes 200 named exports and you import 4 of them, that is routinely 40–120 KB of extra JavaScript reaching the browser. At 100 KB/s mobile parse throughput, that alone adds 400–1200 ms of JavaScript evaluation time before first interaction.

This page covers the architectural workflow for migrating a library to ECMAScript Modules — or configuring your app’s bundler to consume an existing ESM distribution — so that Webpack 5 and Vite 5+ can perform precise dead code elimination, scope hoisting, and deterministic chunk hashing. This technique is one of the primary levers within the broader advanced tree-shaking and dependency optimization strategy: once a library exposes a clean ESM surface, every other pruning technique compounds on top of it.


CJS vs ESM bundler resolution Two-column diagram contrasting how a bundler handles a CJS module (full inclusion, no pruning) versus an ESM module (static analysis, export-level pruning, scope hoisting). CommonJS (require) library/index.js module.exports = { A, B, C … 200 exports } runtime eval Bundler wraps whole module (black-box — cannot prune) Output chunk All 200 exports bundled — ~120 KB No tree-shaking possible ESM (import) library/index.mjs export { A, B, C … 200 named exports } static analysis Bundler traces export graph (marks unused exports — eliminates them) Output chunk 4 exports included — ~8 KB Export-level pruning active

Architectural Context

The advanced tree-shaking and dependency optimization pillar rests on a prerequisite: the bundler must be able to read a static dependency graph. ESM provides that graph. Without it, all other optimisation signals — sideEffects: false annotations, scope hoisting, usedExports marking — operate on a severely constrained input.

CJS-to-ESM conversion therefore sits at the foundation layer of the tree-shaking strategy. Once a dependency exposes a clean ESM entry point, configuring sideEffects for optimal tree-shaking can apply precise export-level pruning, and eliminating dead code with modern build tools can concatenate the remaining modules into tightly scoped output chunks.


The Dual-Package Conditional Exports Pattern

The industry-standard migration strategy uses the exports field in package.json to give bundlers and runtimes different entry points depending on how they resolve the module. When Node.js uses require(), it receives a .cjs artifact. When Webpack 5 or Rollup resolves import, it receives a .mjs artifact with named exports the bundler can statically trace.

// package.json — dual-package conditional exports
{
  "name": "@org/ui-kit",
  "version": "2.0.0",
  "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"
    },
    "./utils/*": {
      "import": "./dist/esm/utils/*.mjs",
      "require": "./dist/cjs/utils/*.cjs"
    }
  },
  "sideEffects": false,
  "main": "./dist/cjs/index.cjs",
  "module": "./dist/esm/index.mjs",
  "types": "./dist/esm/index.d.mts",
  "scripts": {
    "build": "npm run build:esm && npm run build:cjs"
  }
}

Key decisions in this configuration:

  • "type": "module" declares the package root as ESM, so .js files are treated as ESM by default. CJS files must use the .cjs extension.
  • The exports field takes precedence over main and module in Node.js 12+ and in Webpack 5/Rollup. Legacy main and module fields are left in place as fallbacks for older tooling.
  • "sideEffects": false tells Webpack 5 it can safely drop any module from this package that has no used exports. Without this flag, the bundler assumes every module may have observable side effects and cannot prune it even if no export is imported.

TypeScript Build Configuration

Compile both formats from the same source using separate tsconfig files:

// tsconfig.esm.json — ESM output with .mjs extensions
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist/esm",
    "declaration": true,
    "declarationMap": true,
    "declarationDir": "./dist/esm"
  },
  "include": ["src/**/*.ts"]
}
// tsconfig.cjs.json — CJS output with .cjs extensions
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "module": "CommonJS",
    "moduleResolution": "Node",
    "outDir": "./dist/cjs",
    "declaration": true,
    "declarationDir": "./dist/cjs"
  },
  "include": ["src/**/*.ts"]
}
# Build both formats in one pipeline pass
tsc --project tsconfig.esm.json && tsc --project tsconfig.cjs.json

Alternatively, tsup can emit both formats from a single build definition with less configuration duplication:

// tsup.config.ts
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts', 'src/components/*.ts', 'src/utils/*.ts'],
  format: ['esm', 'cjs'],
  dts: true,
  splitting: true,        // produces granular chunks Rollup can tree-shake
  sourcemap: true,
  clean: true,
  outExtension({ format }) {
    return { js: format === 'esm' ? '.mjs' : '.cjs' };
  }
});

Bundler-Specific Configuration

Webpack 5

Webpack 5 picks up conditional exports automatically when conditionNames includes 'import'. Add explicit resolver configuration to prevent fallbacks to legacy main/module fields, and enable the three optimization flags that together perform export-level pruning:

// webpack.config.js — Webpack 5 resolver + tree-shaking configuration
const path = require('path');

module.exports = {
  mode: 'production',
  resolve: {
    // Prefer the ESM branch of the exports map
    conditionNames: ['import', 'module', 'require', 'default'],
    mainFields: ['module', 'main'],
    extensions: ['.mjs', '.js', '.ts', '.json']
  },
  optimization: {
    // Mark used/unused exports in the module graph
    usedExports: true,
    // Drop modules whose sideEffects: false annotation is set and have no used exports
    sideEffects: true,
    // Inline small ESM modules into their importers (scope hoisting)
    concatenateModules: true,
    splitChunks: {
      chunks: 'all',
      minSize: 20000
    }
  },
  module: {
    rules: [
      {
        // Allow .mjs files without requiring fully-qualified specifiers
        test: /\.m?js$/,
        resolve: { fullySpecified: false },
        type: 'javascript/auto'
      }
    ]
  }
};

The three flags work as a chain: usedExports marks exports in the graph, sideEffects triggers removal of modules with no marked exports, and concatenateModules merges the remaining small modules into their importers to eliminate wrapper function overhead.

Vite 5+

Vite’s production builds run through Rollup, which resolves the import condition in exports maps natively. Dev-time dependency pre-bundling via esbuild separately converts CJS to ESM for fast HMR. The key configuration surfaces are optimizeDeps (dev) and build.commonjsOptions (production):

// vite.config.js — Vite 5+ ESM/CJS interop configuration
import { defineConfig } from 'vite';

export default defineConfig({
  optimizeDeps: {
    // Pre-bundle CJS dependencies so they work in dev (esbuild CJS→ESM conversion)
    include: ['@org/ui-kit'],
    exclude: [],    // explicitly exclude packages that must NOT be pre-bundled (pure ESM, ?raw imports)
    esbuildOptions: {
      target: 'es2022'
    }
  },
  build: {
    target: 'es2022',
    commonjsOptions: {
      // Transform hybrid packages that mix ESM and CJS syntax within a file
      transformMixedEsModules: true,
      // Do not include packages that are already pure ESM
      exclude: ['@org/ui-kit']
    },
    rollupOptions: {
      output: {
        // Isolate the UI kit into a named chunk for long-term caching
        manualChunks(id) {
          if (id.includes('node_modules/@org/ui-kit')) return 'ui-kit';
        }
      }
    }
  }
});

Note that commonjsOptions.exclude should list packages that ship native ESM — including them in the CJS transform pass re-wraps them unnecessarily and can break the static analysis Rollup performs.


Framework Integration Examples

React: Lazy-loaded ESM Component

Once a library ships ESM, component-level code splitting via React.lazy and Suspense works without bundler workarounds. The bundler can trace exactly which named exports are used per lazy boundary:

// React — lazy-loading an ESM component from a converted library
import { lazy, Suspense } from 'react';

// Webpack 5 / Rollup: only Button's module and its direct ESM deps are included
const Button = lazy(() =>
  import('@org/ui-kit/components/Button').then(mod => ({ default: mod.Button }))
);

const Modal = lazy(() =>
  import('@org/ui-kit/components/Modal').then(mod => ({ default: mod.Modal }))
);

export function App() {
  return (
    <Suspense fallback={<span>Loading…</span>}>
      <Button variant="primary">Submit</Button>
    </Suspense>
  );
}

Because the exports map exposes ./components/* as a granular path, Webpack 5 can split Button and Modal into separate chunks rather than pulling the full component directory into the initial bundle.

Vue 3: defineAsyncComponent with ESM Library

// Vue 3 — async component from an ESM-converted library
import { defineAsyncComponent } from 'vue';

// Vite splits this into a separate chunk; tree-shaking removes unused Modal internals
const AsyncChart = defineAsyncComponent(() =>
  import('@org/ui-kit/components/Chart').then(mod => mod.Chart)
);

export default {
  components: { AsyncChart }
};

With the granular exports field in place, Vite’s Rollup production pass tree-shakes the Chart module independently of all other components.


Chunk Graph Mechanics: Why CJS Breaks Static Analysis

Webpack 5 and Vite 5+ (via Rollup) build the chunk graph by tracing import declarations lexically at build time. ESM’s import statements are syntactically fixed and evaluated before any module code runs, giving the bundler a complete, immutable dependency graph. CJS’s require() is a function call evaluated at runtime — it can appear inside conditionals, loops, or dynamic expressions. The bundler cannot know at build time which exports will be consumed, so the entire module must be included.

Transitioning to ESM unlocks three compounding optimizations:

  • Module concatenation (scope hoisting): Small ESM modules that have a single importer are inlined directly into the importing module. This eliminates the wrapper function and the extra closure scope, reducing both parse overhead and runtime call depth.
  • Export-level pruning: Named exports with no live references are marked unused harmony export and stripped by the minifier. This requires both sideEffects: false on the library and usedExports: true on the bundler.
  • Deterministic chunk hashing: Because the dependency graph is resolved statically, chunk content hashes are stable across builds that do not touch the relevant modules. Long-term caching via Cache-Control: immutable becomes reliable.

Quantified Impact Metrics

Measured outcomes from migrating production UI component libraries to ESM dual-package with the Webpack 5 / Vite 5+ configuration above:

  • Bundle size reduction: 15–40%. A library exporting 200 components where an app uses 12 drops from ~180 KB (full CJS bundle) to ~22 KB (pruned ESM bundle). Savings scale with the ratio of unused to used exports.
  • JavaScript parse time: 18–22% faster per 100 KB of library code, because ESM produces shallower AST structures than CJS’s wrapper functions and Object.defineProperty calls.
  • Initial chunk count reduction: 30–60% fewer modules in the Webpack stats output when scope hoisting concatenates small helper modules into their importers.
  • Cache hit rate improvement: ~25% better long-term cache efficiency because deterministic chunk hashing prevents unnecessary invalidation across deploys that do not touch the library.
  • CI build time: +8–15 seconds for the dual-format compile step; accept this as infrastructure cost in exchange for the 4–10x larger runtime savings.

Common Pitfalls

Pitfall 1: __esModule Interop Wrapper in Output

Root cause: The library or one of its dependencies sets __esModule: true on a CJS object to simulate ESM interop. Webpack wraps the whole module in a compatibility shim instead of treating it as a native ESM module.

Diagnostic signal: Search dist/ for __esModule:

grep -r '__esModule' dist/

Any hit in your production bundle means the interop wrapper is active and tree-shaking is partially disabled.

Corrective action: Confirm the library’s exports map resolves to a .mjs file (not a .js file that has Object.defineProperty(exports, '__esModule', { value: true })). If you control the library, ensure the ESM build uses native export syntax and does not call the __esModule helper. See fixing tree-shaking failures with Webpack 5 for targeted diagnostic steps.

Pitfall 2: Duplicate Module Instances After Migration

Root cause: Both the ESM and CJS variants of the library end up in the bundle simultaneously — one resolved through the exports map, one through a transitive dependency that still references main.

Diagnostic signal: In Webpack stats (--json), filter for modules matching the library name. If you see both index.mjs and index.cjs variants, you have a dual-loading situation.

Corrective action: Add the library to resolve.alias in Webpack to force a single entry point, or ensure all consumer packages have been updated to a version that exposes the exports field.

Pitfall 3: sideEffects Incorrectly Set to false on a Library That Has Side Effects

Root cause: CSS-in-JS libraries, polyfill packages, or libraries that register globals on import are marked sideEffects: false. Webpack then strips them during tree-shaking, removing the side effect entirely.

Diagnostic signal: A global polyfill stops working, or styles disappear from the page, after upgrading to the ESM version of the library.

Corrective action: Set sideEffects to an array that lists the files with genuine side effects rather than false:

"sideEffects": ["./dist/esm/polyfills/*.mjs", "./dist/esm/styles/*.css"]

The deeper principles behind auditing and correctly annotating sideEffects are covered in how to audit sideEffects in large npm packages.

Pitfall 4: SSR Hydration Mismatch from ESM/CJS Dual Resolution

Root cause: A Next.js or Remix SSR render resolves the require branch of the exports map (Node.js), while client hydration resolves the import branch. If the two builds produce different module instances with different prototype chains or internal state, React’s reconciliation sees a mismatch.

Diagnostic signal: React hydration warnings (Hydration failed because the initial UI does not match) that disappear when you switch the library back to CJS-only.

Corrective action: In Next.js, verify serverExternalPackages (Next.js 14+) or serverComponentsExternalPackages does not force the library to CJS on the server while the client gets ESM. Ensure both builds resolve the same logical version of the module.


Verification Workflow

Step 1: Confirm ESM Resolution at Build Time

Run a production build and inspect Webpack stats for the conditionNames resolution trace:

# Webpack 5 — generate stats for inspection
npx webpack --json > webpack-stats.json
# Check which entry point was resolved for the library
node -e "
  const stats = require('./webpack-stats.json');
  const mods = stats.modules || stats.children?.[0]?.modules || [];
  mods.filter(m => m.name?.includes('@org/ui-kit')).forEach(m => console.log(m.name, m.size));
"

All paths should end in .mjs for an ESM-resolved package. Any .cjs paths indicate a CJS fallback.

Step 2: Check for Unused Export Markers

In Webpack’s output bundle, unused exports are annotated:

# Search for dead exports in Webpack development output
grep -c 'unused harmony export' dist/main.js

Seeing unused harmony export ComponentName in the development bundle confirms tree-shaking is active. A count of zero means either all exports are used (correct) or the bundler cannot trace them (CJS fallback — investigate).

Step 3: Pre/Post Bundle Size Comparison

# measure.sh — compare bundle sizes before and after migration
BEFORE=$(cat baseline-bundle-size.txt)
AFTER=$(du -sk dist/*.js | awk '{sum+=$1} END {print sum}')
echo "Before: ${BEFORE}K  After: ${AFTER}K  Delta: $((AFTER - BEFORE))K"
npx size-limit

Step 4: CI Validation Gate

# .github/workflows/bundle-check.yml — CI gate for ESM compliance
name: Bundle ESM Check
on: [push, pull_request]
jobs:
  bundle:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - run: npm run build
      - name: Validate bundle size
        run: npx size-limit
      - name: Detect CJS interop wrappers
        run: |
          # Webpack 5 — fail if __esModule wrappers appear in production output
          if grep -rq '__esModule' dist/; then
            echo "::warning::__esModule interop wrappers detected. Verify exports mapping."
            exit 1
          fi
      - name: Confirm ESM resolution
        run: |
          # Verify no .cjs files leaked into the stats for this library
          node -e "
            const s = require('./webpack-stats.json');
            const mods = s.modules || [];
            const cjsFallbacks = mods.filter(m => m.name?.includes('@org/ui-kit') && m.name?.endsWith('.cjs'));
            if (cjsFallbacks.length) { console.error(cjsFallbacks); process.exit(1); }
            console.log('ESM resolution confirmed');
          "

Step 5: Browser DevTools Verification

In Chrome DevTools, open the Sources panel after a production build served locally. Navigate to the webpack:// or rollup:// virtual paths for the library. If modules appear as individual .mjs files with export declarations intact, scope hoisting and tree-shaking have run. If you see a single concatenated wrapper with __webpack_require__ boilerplate for every imported symbol, CJS resolution is still active.


Frequently Asked Questions

What happens to tree-shaking when a dependency stays as CommonJS?

Bundlers treat CJS modules as opaque black boxes because require() is evaluated at runtime, not statically. The entire module gets included, unused exports and all, adding 20–80 KB of dead code to production bundles.

Do I need to ship both CJS and ESM outputs?

Yes, if your library is consumed in both Node.js (which may require CJS) and modern bundlers (which benefit from ESM). The conditional exports field in package.json resolves this cleanly: bundlers pick the ESM entry, Node.js CJS environments pick the CJS entry.

Why do I see duplicate module instantiation after migrating to ESM?

Duplicate instances usually mean the exports map is misconfigured and both the ESM and CJS variants are being resolved in the same bundle. Verify that conditionNames in Webpack includes 'import' and that you have not accidentally published both dist/esm/index.js and dist/cjs/index.js with identical extensions.

Can Vite consume a CJS-only package without converting it?

Vite’s dependency pre-bundler (esbuild) converts CJS to ESM at dev time via optimizeDeps, but this conversion is heuristic and cannot guarantee export-level pruning. Production builds via Rollup still cannot tree-shake CJS. Convert the source to enable full optimisation.