Command Palette

Search for a command to run...

React Component Library Performance: Bundle Size, Tree-Shaking & Server-Side Rendering

React Component Library Performance: Bundle Size, Tree-Shaking & Server-Side Rendering

Practical strategies to ship lean, tree-shakable React component libraries that work with SSR and the Next.js App Router — covering packaging, exports, CSS, code-splitting, analyzers, and CI checks.

8 min read·Performance

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, exports maps, per‑component entry points, sideEffects rules, CSS strategies, SSR & App Router nuances, dynamic imports, analyzers, and CI tests that guard size and compatibility.


Table of Contents

  1. Packaging fundamentals (ESM‑first, exports, sideEffects)
  2. Tree‑shaking in practice (avoid hidden side effects)
  3. Build tooling templates (tsup and Rollup)
  4. Per‑component entry points & code splitting
  5. CSS strategies (extraction, treeshake, and SSR)
  6. SSR, App Router, and “use client” boundaries
  7. Peer dependencies & externalization
  8. Icons, images, and third‑party deps
  9. Analyze & enforce with CI
  10. Troubleshooting checklists
  11. Reference folder layout
  12. 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", and exports.import) enables tree‑shaking.
  • Per‑component subpath exports ("./button", "./card") allow targeted imports.
  • sideEffects tells 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 dev paths 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 CSS

Mark 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‑in

If 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

  1. 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.
  2. 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.
  3. Tailwind utilities

    • Provide class names in components; consumers bring Tailwind.
    • Document required content globs 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/document at 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., >=18 for 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-analyzer

Size Limit (CI budget)

pnpm add -D size-limit @size-limit/preset-small-lib

package.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 size

Troubleshooting checklists

Tree‑shaking not working

  • Is the consumer importing from subpaths (@org/ui/button) instead of the root bundle?
  • Is "type": "module" and exports map configured?
  • Any top‑level side effects (CSS injection, singletons, logs)?
  • Is sideEffects set 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 window during 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.

Read more like this

React Component Library Performance: Bundle Size, Tree-Shaking & Server-Side Rendering | Ruixen UI