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.
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 largeindex.js(orindex.mjs) means the library is bundled into a single module. - Read
package.json. Missing"type": "module", missing ESM entry inexports, or"sideEffects"absent ortrueall 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.
Step-by-Step Resolution
1. Build ESM with preserved modules and externals
// 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
{
"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
// 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.
'es' with preserveModules
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.
// 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
moduleorexportsat the source directory. Consumers then compile your TypeScript or JSX with their settings, or fail outright. Always point at built output. - Shipping
exportswithout listing subpaths. Onceexportsexists, any path not listed is inaccessible; consumers who imported deep paths before will break. Enumerate the public entry points deliberately. - Leaving
process.env.NODE_ENVchecks 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
sideEffectschanges. 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.
Related
- Writing tree-shakeable library exports — source-level patterns that shake well.
- Shipping ESM-only packages — dropping CommonJS output entirely.
- Using PURE annotations for side-effect-free calls — marking factory calls as removable.