When you publish a React component library, you’re not just styling buttons — you’re shipping code that becomes part of someone else’s app bundle. Small decisions in packaging and exports determine whether consumers get a few kilobytes or an accidental megabyte. This guide distills the practices that keep your library lean, tree‑shakable, and SSR‑safe.
You’ll learn: ESM‑first packaging,
exportsmaps, per‑component entry points,sideEffectsrules, CSS strategies, SSR & App Router nuances, dynamic imports, analyzers, and CI tests that guard size and compatibility.
Table of Contents
- Packaging fundamentals (ESM‑first, exports, sideEffects)
- Tree‑shaking in practice (avoid hidden side effects)
- Build tooling templates (tsup and Rollup)
- Per‑component entry points & code splitting
- CSS strategies (extraction, treeshake, and SSR)
- SSR, App Router, and “use client” boundaries
- Peer dependencies & externalization
- Icons, images, and third‑party deps
- Analyze & enforce with CI
- Troubleshooting checklists
- Reference folder layout
- FAQ
Packaging fundamentals (ESM‑first, exports, sideEffects)
Modern bundlers (Vite, webpack, Next.js) tree‑shake best with ESM and clear exports.
package.json (library)
{
"name": "@org/ui",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./button": {
"types": "./dist/button/index.d.ts",
"import": "./dist/button/index.js",
"require": "./dist/button/index.cjs"
},
"./card": {
"types": "./dist/card/index.d.ts",
"import": "./dist/card/index.js",
"require": "./dist/card/index.cjs"
},
"./styles.css": "./dist/styles.css",
"./package.json": "./package.json"
},
"sideEffects": ["*.css"],
"files": ["dist"],
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
}Why this matters
- ESM (
"type": "module","module", andexports.import) enables tree‑shaking. - Per‑component subpath exports (
"./button","./card") allow targeted imports. sideEffectstells bundlers they can drop unused modules (but keep CSS).- Peers keep React out of your bundle.
Avoid dual‑package traps (ESM/CJS differences). Ensure both builds have equivalent semantics and pass tests.
Tree‑shaking in practice (avoid hidden side effects)
Tree‑shaking fails when modules have top‑level work. Keep files pure at import time.
Do
- Export named symbols and avoid unnecessary
export *. - Keep module‑level code limited to declarations (no DOM, timers, logging).
- Put optional styles & polyfills behind explicit imports.
- Split test/dev‑only helpers into
devpaths that aren’t exported.
Don’t
- Import a registry that auto‑registers all components.
- Inject CSS on import (it prevents shaking) — see CSS section.
- Import full icon packs from a single entry (
import { A, B, ... } from "all-icons").
Example: treeshakable index
// src/index.ts
export { Button } from "./button";
export { Card } from "./card";
// avoid side effects or wild-card re-exports from barrels that also import CSSMark pure wrappers when needed
/* @__PURE__ */ const withNoop = <P>(c: React.ComponentType<P>) => c;
export const PureButton = /* @__PURE__ */ withNoop(Button);Build tooling templates (tsup and Rollup)
tsup (simple, fast)
// tsup.config.ts
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts", "src/button/index.ts", "src/card/index.ts"],
format: ["esm", "cjs"],
dts: true,
splitting: true,
treeshake: true,
sourcemap: true,
clean: true,
minify: true,
external: ["react", "react-dom"],
});Rollup (fine‑grained control)
// rollup.config.mjs
import path from "node:path";
import dts from "rollup-plugin-dts";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import terser from "@rollup/plugin-terser";
import typescript from "@rollup/plugin-typescript";
const externals = ["react", "react-dom"];
export default [
{
input: {
index: "src/index.ts",
button: "src/button/index.ts",
card: "src/card/index.ts",
},
output: [
{
dir: "dist",
format: "esm",
entryFileNames: "[name].js",
chunkFileNames: "chunks/[name]-[hash].js",
sourcemap: true,
},
{
dir: "dist",
format: "cjs",
entryFileNames: "[name].cjs",
chunkFileNames: "chunks/[name]-[hash].cjs",
sourcemap: true,
},
],
external: externals,
plugins: [
nodeResolve({ extensions: [".mjs", ".js", ".ts", ".tsx"] }),
commonjs(),
typescript({ tsconfig: "./tsconfig.json" }),
terser({ compress: { passes: 2, pure_getters: true }, mangle: true }),
],
treeshake: { moduleSideEffects: false },
},
// Type definitions
{
input: "dist/index.d.ts",
output: { file: "dist/index.d.ts", format: "es" },
plugins: [dts()],
},
];Per‑component entry points & code splitting
Give consumers fine‑grained imports:
// consumer app
import { Button } from "@org/ui/button"; // only pulls what's needed
// or
import "@org/ui/styles.css"; // styles opt‑inIf you ship compound components (e.g., Dialog.{Root,Trigger,Content}), surface them from a single entry so bundlers can still prune unused parts when imported specifically.
CSS strategies (extraction, treeshake, and SSR)
CSS can sabotage tree‑shaking if injected implicitly.
Options
-
Extracted CSS file (recommended for libraries)
- Build a single
dist/styles.css. - Document opt‑in:
import "@org/ui/styles.css"in the consumer’s app shell. - Keep
"sideEffects": ["*.css"]so CSS isn’t dropped.
- Build a single
-
CSS‑in‑JS (runtime injection)
- Great for apps; risky for libraries (runtime cost; potential side effects at import).
- If used, make styles opt‑in and avoid injecting on mere import.
-
Tailwind utilities
- Provide class names in components; consumers bring Tailwind.
- Document required
contentglobs to avoid purge issues.
SSR note: With extracted CSS, SSR is straightforward. With CSS‑in‑JS, provide a documented SSR setup (Emotion/Styled Components SSR) — otherwise hydration mismatches can occur.
SSR, App Router, and “use client” boundaries
- Libraries must be SSR‑safe by default: no
window/documentat module scope. - Components that use hooks or browser APIs should include
"use client"at the top of that file only. Don’t force client mode globally. - For Next.js App Router (RSC), keep behavior in client components and pass plain data/props from server components.
Guard browser‑only code
let canUseDOM = false;
if (typeof window !== "undefined") canUseDOM = true;Client‑only export example
// src/dialog/index.tsx
"use client";
export { Dialog } from "./Dialog";Next dynamic import (consumer)
import dynamic from "next/dynamic";
const Chart = dynamic(() => import("@org/ui/chart"), { ssr: false });Peer dependencies & externalization
Mark heavy deps as peers and external in your build:
react,react-dom,next,framer-motion, charting libs, date libs.- Document version ranges and provide install hints to avoid duplicates.
- Keep peer range reasonable (e.g.,
>=18for React), and test against the lowest & latest.
Icons, images, and third‑party deps
- Prefer per‑icon imports (
import IconX from "lucide-react/lib/icons/x";) or your own tiny SVG components via SVGR. - Avoid bundling full icon packs or heavy moment‑style date libraries.
- For images, ship SVG when possible; leave raster assets to the app layer.
Analyze & enforce with CI
Local analysis
# Rollup
pnpm add -D rollup-plugin-visualizer
# Next.js app (consumer) analyzer
pnpm add -D @next/bundle-analyzerSize Limit (CI budget)
pnpm add -D size-limit @size-limit/preset-small-libpackage.json
{
"size-limit": [
{
"path": "dist/index.js",
"limit": "8 KB"
},
{
"path": "dist/button.js",
"limit": "3 KB"
}
],
"scripts": {
"size": "size-limit"
}
}GitHub Action (excerpt)
name: ci
on: [push, pull_request]
jobs:
build_test_size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: "pnpm" }
- run: corepack enable
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm sizeTroubleshooting checklists
Tree‑shaking not working
- Is the consumer importing from subpaths (
@org/ui/button) instead of the root bundle? - Is
"type": "module"andexportsmap configured? - Any top‑level side effects (CSS injection, singletons, logs)?
- Is
sideEffectsset correctly (only CSS kept)? - Are you using named exports (not a default that forces the whole file)?
SSR errors (“window is not defined”)
- Avoid window/document at module scope.
- Mark hook‑using components with
"use client". - For truly browser‑only widgets, document
next/dynamic(..., { ssr: false })usage.
Bundles too large
- Externalize heavy deps as peers.
- Replace big libs with lighter alternatives (e.g., dayjs/date‑fns subsets).
- Split features into optional entry points.
Hydration mismatch
- Deterministic markup; no random IDs at render time (or seed them).
- Extract CSS or ensure SSR setup for CSS‑in‑JS.
- Don’t conditionally render by
windowduring SSR.
Reference folder layout
ui-lib/
├─ src/
│ ├─ button/
│ │ ├─ Button.tsx
│ │ └─ index.ts
│ ├─ card/
│ │ ├─ Card.tsx
│ │ └─ index.ts
│ ├─ index.ts
│ └─ styles.css
├─ dist/ # build output
├─ tsup.config.ts
├─ package.json
└─ README.md
src/button/index.ts
export { Button } from "./Button";src/index.ts
export { Button } from "./button";
export { Card } from "./card";FAQ
Should a library ship both ESM and CJS?
Yes, for compatibility — but keep your ESM clean and primary. Use exports to point each condition to the right file.
Can I rely on consumers’ tree‑shaking?
Help them: provide subpath exports, avoid side effects, and document best‑import patterns.
Is CSS‑in‑JS a bad idea for libraries?
Not inherently, but extracted CSS is simpler for consumers and easier to tree‑shake.
How do I support React Server Components?
Keep modules RSC‑safe by default (no effects). Mark interactive parts with "use client" and avoid bundling server‑only helpers with client code.
Next steps
- Wire tsup or Rollup with subpath entries and ESM/CJS builds.
- Add size‑limit budgets and a visualizer.
- Audit modules for top‑level side effects and CSS injection.
- Publish a canary, integrate in a small Next.js app, and verify analyzer output.

