How to Build Tree-Shakeable Libraries with Vite Library Mode

This guide covers the producer side of bundle size, within Vite & Rollup Build Optimization in JavaScript Bundle Optimization & Code Splitting. Application teams can only tree-shake what library authors make shakeable. An internal design system or utility package built as a single CommonJS or bundled ESM file forces every consuming app to ship all of it — every component, every icon, every locale — even if a page uses one button.

Vite's library mode (build.lib) builds packages with Rollup, and its defaults produce a single bundled file per format. That is convenient and often wrong for component libraries. The settings that make a library shakeable are well understood: ship ESM, preserve the module structure, externalise dependencies, declare sideEffects accurately, and expose per-feature entry points in package.json exports.

A design-system package, two builds Comparison of a library built as one bundled file with one built as ESM with preserved modules and accurate sideEffects. A design-system package, two builds Single bundled file • dist/index.js contains all 80 components • CSS imported at the top of the bundle • Consumers using 1 button ship 210KB • Any change busts the whole file ESM, preserved modules • dist/button/index.js, dist/modal/index.js ... • CSS per component, sideEffects declared • Consumers using 1 button ship 6KB • Unused components never reach the app

Rapid Diagnosis

  • Build a test app that imports one export from your library and check its bundle with a visualiser. If most of the library appears, it is not shakeable.
  • Inspect dist/. One large index.js (or index.mjs) means the library is bundled into a single module.
  • Read package.json. Missing "type": "module", missing ESM entry in exports, or "sideEffects" absent or true all block tree shaking.
  • Check for top-level side effects in source: global CSS imports, polyfill installs, registry registrations at module scope.

Root Cause Analysis

1. Single-module output. Bundlers tree-shake at the export level, but a single module with internal cross-references and top-level code is hard to prove unused. Preserved modules give the consumer's bundler file-level granularity.

2. Unknown side effects. Without "sideEffects": false (or a precise list), bundlers must assume importing any file might have effects and keep it.

3. Bundled dependencies. Including dependencies like lodash or date-fns inside the library output duplicates them in consumers and prevents deduplication.

4. CommonJS output. CommonJS require and module.exports are dynamic; bundlers cannot reliably shake them.

Consumer bundle cost of importing one Button Bar chart of the bytes a consuming application ships when importing a single component from four library build configurations. Consumer bundle cost of importing one Button CJS single file 214KB ESM single file, sideEffects unset 198KB ESM single file + sideEffects false 64KB ESM preserveModules + sideEffects 6KB

Step-by-Step Resolution

1. Build ESM with preserved modules and externals

javascript
// vite.config.js (library)
import { defineConfig } from 'vite';
import pkg from './package.json' with { type: 'json' };

export default defineConfig({
  build: {
    lib: { entry: 'src/index.ts', formats: ['es'] },
    rollupOptions: {
      external: [...Object.keys(pkg.peerDependencies ?? {}), ...Object.keys(pkg.dependencies ?? {})]
        .map((d) => new RegExp(`^${d}(/.*)?$`)),
      output: { preserveModules: true, preserveModulesRoot: 'src', entryFileNames: '[name].js' },
    },
    cssCodeSplit: true,
    minify: false,                       // let the consuming app minify once
  },
});
// trade-off: preserveModules produces many files; consumers' bundlers handle
// that well, but direct <script type="module"> use from a CDN would mean many
// requests. Ship a separate bundled build if you support that use case.

Expected outcome: dist/ mirrors src/, with dependencies left as bare imports for the consumer to resolve once.

2. Declare side effects precisely

json
{
  "name": "@acme/ui",
  "type": "module",
  "sideEffects": ["**/*.css"],
  "exports": {
    ".": "./dist/index.js",
    "./button": "./dist/button/index.js",
    "./modal": "./dist/modal/index.js",
    "./styles.css": "./dist/style.css"
  }
}

Listing CSS files as side effects keeps component styles from being dropped when the CSS import is the only reference; everything else is declared pure. The consequences of getting this wrong are covered in sideEffects: false broke my CSS imports.

Expected outcome: bundlers drop unused component modules entirely.

3. Remove top-level side effects from source

javascript
// Before: registering at import time makes every import "do something".
import { registry } from './registry';
registry.add('button', Button);
export { Button };

// After: export plainly; let the app opt in to registration.
export { Button };
export function registerAll(r) { r.add('button', Button); /* ... */ }
// trade-off: consumers who relied on automatic registration must call
// registerAll(). Document the change and ship it in a major version.

Expected outcome: importing one component executes nothing beyond defining it.

Library build checklist Four checks that make a Vite library mode build tree-shakeable for consuming applications. Library build checklist formats: 'es' with preserveModules File-level granularity for the consumer's bundler All dependencies external Resolved and deduplicated once in the app sideEffects lists only CSS Every JS module declared pure Per-feature exports in package.json Deep entry points that are part of the public API 1 2 3 4

4. Verify with a consumer fixture in CI

Add a tiny fixture app that imports one export, build it, and assert its output stays small.

javascript
// fixtures/one-button/check.mjs
import { build } from 'vite';
import { statSync, readdirSync } from 'node:fs';
await build({ root: new URL('.', import.meta.url).pathname, logLevel: 'error' });
const js = readdirSync('fixtures/one-button/dist/assets').filter((f) => f.endsWith('.js'));
const bytes = js.reduce((n, f) => n + statSync(`fixtures/one-button/dist/assets/${f}`).size, 0);
if (bytes > 40_000) throw new Error(`one-button fixture is ${bytes} bytes — tree shaking regressed`);
// trade-off: the fixture includes framework runtime bytes too; set the budget
// relative to an empty fixture's size so it measures only your library.

Expected outcome: a pull request that adds a top-level side effect or bundles a dependency fails CI.

Verification

Build the fixture before and after the change and compare its output. Then check a real consuming application's treemap: only the components it renders should appear from your package. Publish with npm pack --dry-run first to confirm the published files match dist/ and the exports map points at real files.

Types, Source Maps and DX Without Cost

Shakeability should not come at the expense of developer experience. Generate .d.ts files alongside the preserved modules (with vite-plugin-dts or tsc --emitDeclarationOnly) and add a types condition to each exports entry, so editors resolve deep imports correctly. Ship source maps with the package for debugging; they are not bundled into consumer output. Keep minify: false in library builds — consumers minify once, and unminified library code produces clearer stack traces and better minification in context because the consumer's minifier sees the whole program.

Common Mistakes When Publishing

  • Pointing module or exports at the source directory. Consumers then compile your TypeScript or JSX with their settings, or fail outright. Always point at built output.
  • Shipping exports without listing subpaths. Once exports exists, any path not listed is inaccessible; consumers who imported deep paths before will break. Enumerate the public entry points deliberately.
  • Leaving process.env.NODE_ENV checks unreplaced in library output when targeting environments that do not define it. Keep them for the consumer's bundler to replace, but document it.
  • Minifying the library. It hampers the consumer's minifier, makes stack traces unreadable and saves nothing once the app is minified.
  • Forgetting a changelog entry for sideEffects changes. Marking modules pure can drop code a consumer relied on for an implicit effect; it deserves a clear release note.

FAQ

Should a library also ship CommonJS?

Only if you have consumers that cannot use ESM — older Node tooling or Jest setups without ESM support. If you do, add it as a separate require condition in exports, and make sure both formats do not end up in one consumer's bundle (the dual package hazard, covered in fixing dual package hazard in a library).

Does a barrel index.js defeat preserveModules?

Not if the barrel only re-exports and every module is side-effect free; modern bundlers follow re-exports and drop the rest. Barrels become a problem when they contain logic or when sideEffects is not declared — see why barrel files break tree shaking.

How should component CSS be shipped?

Per component, imported from the component module and listed in sideEffects, so it is included exactly when the component is. A single global stylesheet is simpler but means every consumer ships every component's styles.

Can consumers tree-shake a library that uses class-based components?

Yes, as long as each class lives in its own module and nothing at module scope references other components. Classes with static initialisers or decorators that register themselves globally count as side effects and block removal; move registration into an explicit function the application calls. Annotating factory calls with /*#__PURE__*/ helps minifiers drop unused instances that the bundler kept.