Build a component library that ships only what consuming apps import — configure proper ESM exports, side-effect-free package.json flags, and code-splitting strategies for lazy components.
Build a component library that ships only what consuming apps import — configure proper ESM exports, side-effect-free package.json flags, and code-splitting strategies for lazy components.
The exports field replaces the outdated main field and enables both tree-shaking and subpath imports. Without a properly configured exports field, bundlers cannot determine what a package exports and often include the entire library:
1{
2 "name": "@acme/react",
3 "version": "2.5.0",
4 "type": "module",
5 "sideEffects": false,
6 "exports": {
7 ".": {
8 "import": "./dist/index.esm.js",
9 "require": "./dist/index.cjs.js",
10 "types": "./dist/index.d.ts"
11 },
12 "./button": {
13 "import": "./dist/button.esm.js",
14 "require": "./dist/button.cjs.js",
15 "types": "./dist/button.d.ts"
16 },
17 "./date-picker": {
18 "import": "./dist/date-picker.esm.js",
19 "require": "./dist/date-picker.cjs.js",
20 "types": "./dist/date-picker.d.ts"
21 },
22 "./styles": "./dist/styles.css"
23 },
24 "main": "./dist/index.cjs.js",
25 "module": "./dist/index.esm.js",
26 "types": "./dist/index.d.ts"
27}Subpath exports let teams import @acme/react/button and pay only for Button's bundle, not the entire library. This is critical when a library includes heavyweight components like DatePicker or RichTextEditor.
The sideEffects: false flag tells bundlers that importing from the package will not cause side effects (no global CSS injection, no prototype mutation, no event listeners registered at module scope). This enables aggressive dead code elimination:
1{
2 "sideEffects": false
3}If your package imports CSS directly (import './button.css'), those files have side effects and must be excluded:
1{
2 "sideEffects": ["*.css", "dist/register-icons.js"]
3}For the component source itself, ensure pure ESM with no CommonJS patterns that prevent static analysis:
1// GOOD: static named exports — tree-shakable
2export { Button } from './Button/Button';
3export type { ButtonProps } from './Button/Button.types';
4export { Input } from './Input/Input';
5
6// BAD: dynamic requires or barrel re-exports that lose tree-shaking
7// const components = require('./all-components');
8// export * from './all-components'; // only bad if the source uses CJSVite's library mode produces optimized ESM and CJS bundles with proper chunk splitting:
1// packages/react/vite.config.ts
2import { defineConfig } from 'vite';
3import react from '@vitejs/plugin-react';
4import dts from 'vite-plugin-dts';
5import { resolve, join } from 'path';
6import { readdirSync } from 'fs';
7
8// Auto-discover entry points from src directory
9const entries = Object.fromEntries(
10 readdirSync('src')
11 .filter(name => !name.startsWith('_') && !name.endsWith('.test.ts'))
12 .map(name => [
13 name.replace(/\.tsx?$/, ''),
14 resolve('src', name, 'index.ts'),
15 ])
16);
17
18export default defineConfig({
19 plugins: [react(), dts({ rollupTypes: true })],
20 build: {
21 lib: {
22 entry: entries,
23 formats: ['es', 'cjs'],
24 },
25 rollupOptions: {
26 external: ['react', 'react-dom', 'react/jsx-runtime'],
27 output: {
28 preserveModules: true,
29 preserveModulesRoot: 'src',
30 entryFileNames: '[name].[format].js',
31 },
32 },
33 },
34});preserveModules: true maintains the source file structure in dist, enabling per-component subpath imports without manual entry configuration.
Some components (DatePicker, RichTextEditor, DataGrid) are inherently heavy. Wrap them in React.lazy to defer loading until needed:
1// packages/react/src/date-picker/index.ts
2// Heavy: includes date-fns (80KB), popover, calendar grid
3export { DatePicker } from './DatePicker';
4export type { DatePickerProps } from './DatePicker.types';
5
6// In consuming app:
7import { lazy, Suspense } from 'react';
8
9const DatePicker = lazy(() =>
10 import('@acme/react/date-picker').then(m => ({ default: m.DatePicker }))
11);
12
13function BookingForm() {
14 return (
15 <Suspense fallback={<input type="date" aria-label="Select date" />}>
16 <DatePicker
17 label="Check-in date"
18 onChange={handleDateChange}
19 />
20 </Suspense>
21 );
22}The Suspense fallback is a native date input — this ensures users without JavaScript can still submit the form and users on slow connections get a functional fallback immediately. Document this lazy-loading pattern in the component's Storybook page so consuming teams know to wrap it in Suspense.
Trust but verify: use bundle analyzer tools to confirm tree-shaking is eliminating dead code:
1# Option 1: rollup-plugin-visualizer (add to vite.config.ts)
2npm install --save-dev rollup-plugin-visualizer1// vite.config.ts (in consuming app's build config)
2import { visualizer } from 'rollup-plugin-visualizer';
3
4export default defineConfig({
5 plugins: [
6 visualizer({
7 filename: 'bundle-report.html',
8 gzipSize: true,
9 brotliSize: true,
10 open: true, // Opens report in browser after build
11 }),
12 ],
13});1// Option 2: import-cost check via size-limit with import
2// .size-limit.json
3[
4 {
5 "name": "Button only — should be < 3KB",
6 "import": "{ Button }",
7 "path": "packages/react/dist/index.esm.js",
8 "limit": "3 KB"
9 },
10 {
11 "name": "DatePicker only — should be < 90KB",
12 "import": "{ DatePicker }",
13 "path": "packages/react/dist/index.esm.js",
14 "limit": "90 KB"
15 }
16]If Button's tree-shaken size is unexpectedly large (over 10KB), search the bundle report for leaking dependencies. Common culprits: circular imports, barrel files that import the entire library, or peer dependencies not listed in peerDependencies.
Add the exports field to your design system package.json with at least one subpath export. Run size-limit with an import-specific test for your heaviest component and verify tree-shaking is working.
Your progress is saved in this browser, on this device only.