Vite 16 ⚡ Library Mode — Building distributable packages
Vite’s library mode is the configuration path for building a package that another project will consume, rather than an application that a browser will load. The distinction matters because the defaults change. An application build assumes it owns the entire dependency tree and can bundle everything. A library build assumes the consumer owns the dependency tree and must not duplicate what the consumer already has. The build.lib option exists to enforce that assumption, but it does not enforce it automatically. Externalizing peer dependencies, generating type declarations, and shaping the package.json exports map are all manual steps, and each one has a failure mode that produces a library that installs cleanly and breaks at runtime.
The Vite documentation describes build.lib in functional terms: it marks the build as a library, requires an entry because a library cannot use HTML as an entry point, and sets default output formats to ['es', 'umd'] for a single entry or ['es', 'cjs'] for multiple entries . What the documentation does not say loudly enough is that the default formats are the least interesting part of the configuration. The interesting parts are the externals and the type declarations, and neither has a sensible default.
Key point: Library mode changes what Vite bundles and what it leaves out. The build.lib option handles the output format. rolldownOptions.external handles the dependency boundary. Type declarations require a separate plugin. A library that skips any of these three steps will either ship duplicate dependencies, fail to resolve types in the consumer, or both.
Why library mode exists
The application-versus-library default problem. Vite’s default build is optimized for applications. It bundles every dependency into the output because an application is the final consumer of its own bundle. A library is not the final consumer. When a React component library bundles its own copy of React, the consumer ends up with two React instances, two copies of the React runtime, and hooks that throw the “invalid hook call” error because the component is reading from a different React than the consumer’s tree. The fix is to mark React as external, which tells Vite to leave it out of the bundle and assume the consumer provides it . This is not optional for peer dependencies.
The type declaration gap. Vite does not generate TypeScript declaration files. It transforms and bundles TypeScript, but the .d.ts files that tell a consumer’s editor what the library’s API looks like are not part of the bundling output. A library built with Vite alone ships JavaScript without types, which means TypeScript consumers see any everywhere or, worse, see no types at all and lose autocomplete. The vite-plugin-dts plugin is the standard solution: it runs the TypeScript compiler’s declaration emission alongside the Vite build and places the .d.ts files in the output directory .
The exports map complexity. A library that supports both ESM and CommonJS needs a package.json that tells the resolver which file to load in which environment. The exports field does this conditionally: import points to the ESM build, require points to the CJS or UMD build, and types points to the declaration file. The main and module fields are fallbacks for older resolvers that do not understand exports . Getting this map wrong produces a library that works in the developer’s test project and fails when a consumer with a different module resolution strategy installs it.
The trade-off. Library mode adds configuration that an application never needs. The external array must be kept in sync with peerDependencies. The exports map must match the actual output filenames. The type plugin must be configured to include the right source directory. Each of these is a maintenance burden. The alternative—shipping a bundle with everything inlined—produces a library that works in isolation and conflicts with every consumer that has its own copy of the shared dependency.
a. Configuring build.lib and externalizing peer dependencies
The build.lib block defines the entry, the output formats, and the filenames. The rolldownOptions.external array defines what Vite must not bundle. The two work together: lib says “this is a library,” external says “these dependencies are the consumer’s responsibility.”
// vite.config.ts — Vite 8 library configuration
import { defineConfig } from 'vite';
import { resolve } from 'node:path';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
export default defineConfig({
plugins: [
react(),
// dts generates .d.ts files alongside the build output.
// Without it, TypeScript consumers see no types.
dts({ include: ['src'], rollupTypes: true }),
],
build: {
lib: {
// The entry point of the library.
// A library cannot use HTML as an entry, so this is required.
entry: resolve(__dirname, 'src/index.ts'),
// The global variable name for UMD builds.
// Required when formats includes 'umd' or 'iife'.
name: 'MyLib',
// The filename pattern for each format.
fileName: (format) => `my-lib.${format}.js`,
},
rolldownOptions: {
// Every peer dependency must be externalized.
// If React is bundled here, the consumer gets a second copy.
external: ['react', 'react-dom', 'react/jsx-runtime'],
output: {
// The globals map tells the UMD build what global variable
// to use for each external dependency.
globals: {
react: 'React',
'react-dom': 'ReactDOM',
},
},
},
},
});
The external array is the security boundary of the library. A dependency that appears in peerDependencies but not in external is bundled into the output, which means the consumer’s copy and the bundled copy coexist. For React, this produces the invalid hook call error. For a smaller utility library, it produces two copies of the same functions and a larger bundle than necessary . The external array and the peerDependencies object must be kept in sync manually; there is no automatic linkage.
b. Shaping the package.json for dual-format consumption
The package.json of a library is the contract that the resolver reads. The exports field is the modern mechanism; main and module are fallbacks for older resolvers that do not understand exports .
{
"name": "my-lib",
"version": "1.0.0",
"type": "module",
"files": ["dist"],
"main": "./dist/my-lib.umd.cjs",
"module": "./dist/my-lib.es.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/my-lib.es.js",
"require": "./dist/my-lib.umd.cjs"
},
"./style.css": "./dist/my-lib.css"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"sideEffects": ["**/*.css"]
}
The types entry inside the exports map is what tells TypeScript where to find the declarations when the consumer uses moduleResolution: "bundler" or "node16". The top-level types field is the fallback for older TypeScript configurations . The sideEffects array marks CSS files as having side effects so that tree-shaking does not remove the style imports that the library depends on .
The peerDependencies range should be as permissive as the library actually supports. A range like "^18.0.0 || ^19.0.0" tells the consumer that either React 18 or 19 is compatible. A range that is too narrow forces the consumer to upgrade or downgrade; a range that is too wide promises compatibility that the library has not tested.
c. Multiple entries and the exports subpath mapping
A library with more than one entry point—a main export and a utilities export, for example—uses an object for entry and a matching exports map with subpaths.
// vite.config.ts — multiple entries
export default defineConfig({
build: {
lib: {
entry: {
index: resolve(__dirname, 'src/index.ts'),
utils: resolve(__dirname, 'src/utils/index.ts'),
},
// Multiple entries default to ['es', 'cjs'].
// UMD is not supported for multiple entries.
formats: ['es', 'cjs'],
fileName: (format, entryName) => `${entryName}.${format}.js`,
},
rolldownOptions: {
external: ['react', 'react-dom'],
},
},
});
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.es.js",
"require": "./dist/index.cjs.js"
},
"./utils": {
"types": "./dist/utils/index.d.ts",
"import": "./dist/utils.es.js",
"require": "./dist/utils.cjs.js"
}
}
}
The subpath exports allow consumers to import from my-lib/utils without pulling in the entire main entry. This is the mechanism that makes tree-shaking effective at the package boundary: a consumer that only needs the utilities can import the subpath and avoid the main bundle entirely . The entry keys must match the exports subpath keys, or the consumer will import a file that does not exist.
Complete Example Session
// ============================================
// PART 1: THE SOURCE FILES
// ============================================
// src/index.ts
export { Button } from './Button';
export { useCounter } from './useCounter';
// src/Button.tsx
import { useState } from 'react';
export function Button({ label }: { label: string }) {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{label}: {count}</button>;
}
// ============================================
// PART 2: THE VITE CONFIG WITH EXTERNALS AND TYPES
// ============================================
// vite.config.ts
import { defineConfig } from 'vite';
import { resolve } from 'node:path';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
export default defineConfig({
plugins: [
react(),
dts({ include: ['src'], rollupTypes: true }),
],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'MyLib',
fileName: (format) => `my-lib.${format}.js`,
},
rolldownOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime'],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
},
},
},
},
});
// ============================================
// PART 3: THE PACKAGE.JSON CONTRACT
// ============================================
// package.json
{
"name": "my-lib",
"version": "1.0.0",
"type": "module",
"files": ["dist"],
"main": "./dist/my-lib.umd.cjs",
"module": "./dist/my-lib.es.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/my-lib.es.js",
"require": "./dist/my-lib.umd.cjs"
},
"./style.css": "./dist/my-lib.css"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"sideEffects": ["**/*.css"]
}
// ============================================
// PART 4: THE FAILURE MODE — BUNDLED PEER DEPENDENCY
// ============================================
// vite.config.ts — WRONG
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'MyLib',
},
// No rolldownOptions.external.
// React is bundled into the library.
// The consumer's React and the bundled React coexist.
// Result: "Invalid hook call" error.
},
});
// ============================================
// PART 5: THE FAILURE MODE — MISSING TYPES
// ============================================
// vite.config.ts — WRONG
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
},
},
// No dts plugin.
// The build produces JavaScript but no .d.ts files.
// TypeScript consumers see "Could not find a declaration file for module 'my-lib'".
});
// ============================================
// PART 6: MULTIPLE ENTRIES WITH SUBPATH EXPORTS
// ============================================
// vite.config.ts — multiple entries
export default defineConfig({
build: {
lib: {
entry: {
index: resolve(__dirname, 'src/index.ts'),
utils: resolve(__dirname, 'src/utils/index.ts'),
},
formats: ['es', 'cjs'],
fileName: (format, entryName) => `${entryName}.${format}.js`,
},
rolldownOptions: {
external: ['react', 'react-dom'],
},
},
});
// package.json — matching exports
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.es.js",
"require": "./dist/index.cjs.js"
},
"./utils": {
"types": "./dist/utils/index.d.ts",
"import": "./dist/utils.es.js",
"require": "./dist/utils.cjs.js"
}
}
}
// ============================================
// PART 7: PRESERVING TREE-SHAKING
// ============================================
// The 'es' format output in lib mode does not have whitespace minified
// by default, because minifying whitespace removes pure annotations
// and breaks tree-shaking .
//
// This is intentional. A library's ESM output is meant to be tree-shaken
// by the consumer's bundler. Aggressive minification defeats that.
//
// If you need to verify tree-shaking works:
// 1. Build the library.
// 2. Create a test project that imports one export.
// 3. Run the test project's build and inspect the bundle.
// 4. The unused exports should not appear.
// ============================================
// PART 8: CSS HANDLING
// ============================================
// If the library imports CSS, Vite extracts it into a separate file.
// The cssFileName option controls the output name.
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
cssFileName: 'my-lib-style',
},
},
});
// The consumer imports the CSS explicitly:
// import 'my-lib/style.css';
//
// The exports map must include the CSS path:
// "./style.css": "./dist/my-lib-style.css"
// ============================================
// PART 9: VERIFICATION BEFORE PUBLISHING
// ============================================
// 1. Build: npm run build
// 2. Inspect dist/ — verify .es.js, .umd.cjs, .d.ts exist
// 3. Pack: npm pack --dry-run
// 4. Install the tarball in a test project: npm install ./my-lib-1.0.0.tgz
// 5. Import and use the library in the test project
// 6. Verify the consumer's build does not include a second React
// 7. Verify TypeScript autocomplete works in the test project
// ============================================
// PART 10: VITE 8 CHANGES THAT AFFECT LIBRARY MODE
// ============================================
// Vite 8 uses Rolldown as the bundler.
// The option is build.rolldownOptions, not build.rollupOptions.
// build.rollupOptions is auto-converted with a deprecation warning .
//
// The external option still works the same way.
// The output.globals option still works the same way.
//
// The main change is the bundler underneath.
// For library mode, the configuration surface is largely unchanged.
The ten parts covered the configuration, the package contract, the two failure modes, the multiple-entry pattern, the tree-shaking constraint, the CSS handling, and the verification steps. The through-line is that library mode is a contract with the consumer: the library promises to provide the code and types, and the consumer promises to provide the peer dependencies.
Quick Reference
build.lib options
| Option | Type | Required | Default |
|---|---|---|---|
entry | string | string[] | Record<string, string> | Yes | — |
name | string | Yes for UMD/IIFE | — |
formats | ('es' | 'cjs' | 'umd' | 'iife')[] | No | ['es', 'umd'] or ['es', 'cjs'] |
fileName | string | (format, entryName) => string | No | package.json name |
cssFileName | string | No | fileName or package name |
Peer dependency checklist
| Step | Configuration | Consequence if missed |
|---|---|---|
| Externalize | rolldownOptions.external | Duplicate dependency in consumer |
| Declare | peerDependencies in package.json | Consumer installs without warning |
| Map globals | output.globals | UMD build fails in browser |
| Generate types | vite-plugin-dts | TypeScript consumers see no types |
| Map exports | exports field in package.json | Resolver fails to find the file |
Export conditions
| Condition | File | Resolver |
|---|---|---|
import | .es.js | ESM-aware bundlers and Node.js |
require | .umd.cjs | CommonJS Node.js |
types | .d.ts | TypeScript |
default (fallback) | Any | Last resort |
Best Practices
✅ Do This:
// Externalize every peer dependency
external: ['react', 'react-dom', 'react/jsx-runtime'] // ✅
// Generate type declarations
dts({ include: ['src'], rollupTypes: true }) // ✅
// Map exports with types first
"./": { "types": "./dist/index.d.ts", "import": "...", "require": "..." } // ✅
// Mark CSS as a side effect
"sideEffects": ["**/*.css"] // ✅
// Verify with npm pack before publishing
npm pack --dry-run // ✅
❌ Don’t Do This:
// Bundle React into the library
// No external array // ❌
// Skip type declarations
// No dts plugin // ❌
// Use a single format for a dual-format consumer
formats: ['es'] // CommonJS consumers cannot require it // ❌
// Forget the globals map for UMD
// The UMD build references `undefined` for React // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| “Invalid hook call” in consumer | React bundled into the library | Add React to external and peerDependencies |
| “Could not find a declaration file” | No .d.ts output | Add vite-plugin-dts |
import resolves to the wrong file | exports map order or paths wrong | Put types first, verify filenames match |
| CSS missing after tree-shaking | CSS not marked in sideEffects | Add "sideEffects": ["**/*.css"] |
| UMD build fails in browser | Missing output.globals | Map each external to its global variable |
| Subpath import fails | entry keys do not match exports keys | Align the object keys |
Real-World Examples
1. Basic library config
lib: { entry: 'src/index.ts', name: 'MyLib', fileName: 'my-lib' }
2. Externalizing React
rolldownOptions: { external: ['react', 'react-dom', 'react/jsx-runtime'] }
3. Generating types
dts({ include: ['src'], rollupTypes: true })
4. Dual-format exports
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/my-lib.es.js", "require": "./dist/my-lib.umd.cjs" } }
5. Multiple entries
entry: { index: 'src/index.ts', utils: 'src/utils/index.ts' }
6. CSS side effects
"sideEffects": ["**/*.css"]
7. UMD globals
output: { globals: { react: 'React', 'react-dom': 'ReactDOM' } }
8. Peer dependencies
"peerDependencies": { "react": "^18.0.0 || ^19.0.0" }
9. Subpath exports
"./utils": { "types": "./dist/utils/index.d.ts", "import": "./dist/utils.es.js" }
10. Verification
npm pack --dry-run
Visual
The Library Boundary
┌──────────────────────────────────────────────────────────────┐
│ WHAT VITE BUNDLES AND WHAT IT LEAVES OUT │
│ │
│ The library source │
│ ┌──────────────────────────────────────────────┐ │
│ │ src/index.ts │ │
│ │ import { useState } from 'react'; │ │
│ │ export function Button() { ... } │ │
│ └──────────────────────┬───────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Vite build with external: ['react'] │ │
│ └──────────────────────┬───────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ dist/my-lib.es.js │ │
│ │ export function Button() { ... } │ │
│ │ // import { useState } from 'react' remains │ │
│ │ // React is NOT in the bundle │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ The consumer's bundler resolves React from its own node_modules│
│ The consumer gets one React, not two. │
└──────────────────────────────────────────────────────────────┘
The Two Failure Modes
┌──────────────────────────────────────────────────────────────┐
│ WHAT BREAKS WITHOUT THE TWO CRITICAL STEPS │
│ │
│ Missing external Missing types │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ React bundled │ │ JS shipped │ │
│ │ │ │ No .d.ts │ │
│ │ Consumer installs │ │ │ │
│ │ the library │ │ TypeScript sees │ │
│ │ │ │ "any" or errors │ │
│ │ Two React copies │ │ │ │
│ │ Invalid hook call │ │ Autocomplete gone │ │
│ └────────────────────┘ └────────────────────┘ │
│ │
│ Both are silent at install time. Both fail at use time. │
└──────────────────────────────────────────────────────────────┘
The Exports Map Decision Tree
┌──────────────────────────────────────────────────────────────┐
│ WHICH FILE DOES THE RESOLVER LOAD? │
│ │
│ Consumer writes: import { Button } from 'my-lib' │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Resolver reads │ │
│ │ package.json │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ import condition│ │ require condition│ │
│ │ (ESM) │ │ (CommonJS) │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ dist/my-lib.es │ │ dist/my-lib.umd │ │
│ │ .js │ │ .cjs │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ The "types" condition is checked first by TypeScript. │
│ If it is missing, types fall back to the top-level "types".│
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Configuration option | build.lib |
| Required entry | Yes; HTML is not allowed as a library entry |
| Default formats (single entry) | ['es', 'umd'] |
| Default formats (multiple entries) | ['es', 'cjs'] |
| Peer dependency handling | rolldownOptions.external |
| Type generation | vite-plugin-dts |
| Package export map | exports with types, import, require |
| Tree-shaking preservation | es format does not minify whitespace by default |
| CSS handling | cssFileName; mark in sideEffects |
| Verification | npm pack --dry-run |
Key takeaways:
- Externalization is the first responsibility. A library that bundles its peer dependencies ships duplicate runtimes. For React, this produces the invalid hook call error. The
rolldownOptions.externalarray and thepeerDependenciesobject must be kept in sync, and theoutput.globalsmap must match the externals for UMD builds . - Types are not generated by default. Vite bundles JavaScript. The
.d.tsfiles that describe the API requirevite-plugin-dtsor a separatetsc --emitDeclarationOnlystep. A library without types is a library that TypeScript consumers cannot use effectively . - The
exportsmap is the resolver contract. Theimportcondition points to the ESM build, therequirecondition points to the CJS/UMD build, and thetypescondition points to the declarations. The top-levelmain,module, andtypesfields are fallbacks for older resolvers . - The ESM output is not minified by default. Vite’s library mode avoids whitespace minification for the
esformat because it removes pure annotations and breaks tree-shaking. The library’s ESM output is meant to be tree-shaken by the consumer’s bundler, not by the library’s build . - Verification is a step, not an afterthought.
npm pack --dry-runshows what will be published. Installing the tarball in a test project and building it verifies that the externalization and the exports map work in the consumer’s environment. The failure modes are silent at publish time and loud at use time.
Remember: Library mode is a contract between the library and the consumer. The library provides the code and the types. The consumer provides the peer dependencies. Vite’s build.lib option handles the output format, but it does not enforce the contract. The external array, the peerDependencies object, the exports map, and the declaration plugin are the four manual steps that make the contract work. Skipping any of them produces a library that installs cleanly and fails when the consumer tries to use it. The build succeeds either way. The difference is whether the consumer’s build succeeds too.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!