All topics
Vite

7 min read

Library Mode & Code-Splitting

Externalizing peer dependencies in library mode, dynamic `import()` as the real unit of code-splitting, and manualChunks grouped by actual sharing.

Principles

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.

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.

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.

`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.

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

What goes wrong, what to do instead, and why the difference matters.

Library mode without externalizing peer dependencies

Avoid

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.

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

Prefer

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

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

A heavy feature that can never become its own chunk

Avoid

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".

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

Prefer

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.

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

manualChunks without a real shared-dependency rationale

Avoid

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.

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

Prefer

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
        },
      },
    },
  },
});

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

Review checklist

What I look for when reviewing a pull request that touches this area.

  • `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.

Take the skill

Everything above, generated into a skill file your agent can load. Copy it, or download it and commit it to your repo.

Download
.claude/skills/vite-library-and-code-splitting/SKILL.md

Drop into .claude/skills/ or .cursor/skills/ — the frontmatter drives when the skill loads.

---
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