---
name: vite-library-and-code-splitting
description: >-
  Vite build.lib configuration and code-splitting: externalizing peer
  dependencies, dynamic import boundaries, manualChunks grouped by real
  sharing, and deliberate preloading. Use when building a library or
  optimizing bundle output.
argument-hint: "[library or bundle]"
license: MIT
metadata:
  author: John Felix Lim
  version: "1.0.0"
---
# Library Mode & Code-Splitting

Apply when configuring build.lib or investigating bundle size. Check whether a heavy feature is behind a dynamic import before reaching for manual chunk configuration to solve the same problem.

## Rules

1. **Library mode and app mode optimize for different things** — `build.lib` produces an unbundled-dependencies output meant to be consumed by another build; an app build inlines everything for a browser to run directly. Configuring one like the other produces either a bloated library or an app that can't tree-shake.
2. **A library's `external` list is a promise, not a suggestion** — Dependencies marked external must actually be present in whatever consumes the library. Forgetting one there ships a library that throws at runtime in every consumer.
3. **Dynamic `import()` is the unit of code-splitting** — Vite/Rollup splits at every dynamic import boundary automatically. A route or heavy feature that's statically imported can never become its own chunk no matter how `build.rollupOptions` is configured.
4. **`manualChunks` is for shared dependencies, not for guessing at performance** — Grouping by 'things that are always used together' — a charting library used on three routes — avoids duplicate code across chunks. Grouping arbitrarily just moves bytes around without reducing total size.
5. **Preload what you know is coming, not everything** — `<link rel="modulepreload">` for the next likely route helps. Blanket-preloading every chunk defeats the purpose of splitting in the first place.

## Patterns

### Library mode without externalizing peer dependencies

**Avoid** — Peer dependencies get duplicated into the library bundle, bloating every consumer and risking two React instances at runtime.

```ts
// build.lib output, no external — React gets bundled INTO the library
export default defineConfig({
  build: { lib: { entry: "src/index.ts", formats: ["es"] } },
});
// Every consumer now ships their own React *and* a second copy
// bundled inside this library.
```

**Prefer** — The library declares its peer dependencies instead of bundling them, so consumers get exactly one copy of React.

```ts
export default defineConfig({
  build: {
    lib: { entry: "src/index.ts", formats: ["es"] },
    rollupOptions: {
      external: ["react", "react-dom"], // consumer provides these
    },
  },
});
```

### A heavy feature that can never become its own chunk

**Avoid** — A static import means the module is part of the initial bundle regardless of whether it's ever used.

```tsx
import { PDFExporter } from "./PDFExporter"; // pulls in a large PDF lib

function ReportPage() {
  return <button onClick={() => new PDFExporter().export()}>Export PDF</button>;
}
// The PDF library ships in the main bundle even though most
// visitors never click "Export PDF".
```

**Prefer** — The dynamic import boundary is exactly where the feature is actually used, so its cost is paid only by visitors who use it.

```tsx
function ReportPage() {
  async function handleExport() {
    const { PDFExporter } = await import("./PDFExporter");
    new PDFExporter().export();
  }
  return <button onClick={handleExport}>Export PDF</button>;
}
// The PDF library becomes its own chunk, fetched only on click.
```

### manualChunks without a real shared-dependency rationale

**Avoid** — Grouping everything into one 'vendor' chunk means a single dependency bump invalidates the cache for all of them.

```ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ["lodash", "date-fns", "zod", "react", "chart.js"], // everything
        },
      },
    },
  },
});
// One giant chunk that changes — and gets re-downloaded in full —
// every time any dependency in it updates.
```

**Prefer** — Chunks are grouped by actual sharing and change frequency, so a dependency bump invalidates only the chunk it belongs to.

```ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          charts: ["chart.js"],          // shared across the 3 routes that chart
          react: ["react", "react-dom"], // rarely changes, cache-friendly
        },
      },
    },
  },
});
```

## Review checklist

- [ ] `build.lib` externalizes peer dependencies (React, etc.) rather than bundling them into the library output.
- [ ] Heavy or rarely-used features are behind a dynamic `import()`, not a static import that forces them into the main bundle.
- [ ] `manualChunks` groupings reflect actual shared usage and change frequency, not an arbitrary split.
- [ ] The production bundle's chunk sizes were actually inspected (`vite build` plus a visualizer), not assumed reasonable.
- [ ] Preloading targets the next likely navigation, not every chunk in the app.
- [ ] Library mode output was tested from a real consuming project, not just built successfully in isolation.

---

From the John Felix Lim Frontend Engineering Playbook — https://github.com/JohnFelixLim