Vite Library Mode and Package Bundling
Shipping a reusable component or utility package is a different problem from shipping an application: the output is consumed by another bundler, so the artifacts must be ESM-first, CommonJS-compatible, externally-linked against peer dependencies, and shipped with .d.ts declarations and a correct exports map. Vite’s build.lib mode reconfigures the underlying Rollup pipeline for exactly this target. For the broader configuration surface this builds on, see the Vite Configuration & Ecosystem overview before pinning library output formats. This guide covers the complete build.lib configuration, multi-format output (es/cjs/umd), externalizing dependencies, generating types with vite-plugin-dts, wiring package.json exports/main/module/types, CSS handling, and preserving the module graph for tree-shaking.
Problem Statement: Why Application Bundling Defaults Are Wrong for Packages
A default vite build assumes a deployable application: it bundles every dependency, hashes filenames, injects an HTML entry, and emits a single optimized graph for browsers you control. A published package inverts every one of those assumptions. The consumer’s bundler — Webpack, another Vite, esbuild, Rollup — owns final tree-shaking, minification, and code-splitting. If your package bundles React, every consumer ships two copies and React’s useState identity checks break across the boundary. If it emits only ESM, legacy CommonJS toolchains throw ERR_REQUIRE_ESM. If it ships no .d.ts, TypeScript consumers get Could not find a declaration file. build.lib exists to flip these defaults: stable filenames, externalized dependencies, multiple module formats, and a flat, predictable output addressable by package.json.
Prerequisites
- Vite 5.x or 6.x (
vite@^5.4orvite@^6.0).build.libhas been stable since Vite 2, but thelib.fileNamecallback signature and multiple-entry support assumed here require 4.0+. - Rollup 4.x, which Vite 5/6 vendors. You do not install it directly; it ships inside
vite. - Node 18.18+ (Vite 5) or Node 18.18+/20.19+ (Vite 6). Confirm with the Vite version compatibility reference before pinning a toolchain.
vite-plugin-dts@^4for declaration generation, plus atsconfig.jsonwith"declaration": trueand a configuredinclude.- A
package.jsonwith"type": "module"for clean ESM-default resolution.
npm install -D vite@^6 vite-plugin-dts@^4 typescript
Core Mechanics
build.lib rewires the Rollup output config
When build.lib.entry is set, Vite stops generating an HTML-driven build and instead treats the entry (or entries) as Rollup input. build.lib.formats maps to one Rollup output object per format. es and cjs produce a faithful module graph; umd and iife require a single entry and a name (the global variable) because they must collapse to one self-executing closure. Vite disables CSS code-splitting in lib mode by default and emits a single style.css next to the bundle.
external removes dependencies from the graph
build.rollupOptions.external tells Rollup to leave matching import specifiers as bare import/require statements rather than inlining their source. This is how peer dependencies stay un-bundled. For umd/iife you must also supply output.globals mapping each external to the global name it occupies on window (e.g. react → React). The mechanics of a peerDependencies-driven external are covered in depth in externalizing peer dependencies in Vite library mode.
Declarations are out-of-band
Vite’s core does not run the TypeScript type checker, so it emits no .d.ts. vite-plugin-dts hooks the build, runs the TS compiler in emitDeclarationOnly mode, optionally rolls declarations into a single file (rollupTypes: true), and writes them into outDir. This is a separate compilation pass from the JS bundling and has its own tsconfig resolution.
Preserving modules vs. bundling
By default es output is a single file. Setting output.preserveModules: true mirrors the source tree into outDir, emitting one file per source module. This maximises downstream tree-shaking (consumers import only the files they touch) at the cost of more files and no cross-module inlining. It is incompatible with umd/iife.
Configuration & CLI Reference
The following is a complete, runnable vite.config.ts for a TypeScript component library with externalized peers, all three formats, and type generation.
// vite.config.ts — Vite 6.x / Rollup 4.x / vite-plugin-dts 4.x
import { defineConfig } from 'vite';
import { resolve } from 'node:path';
import dts from 'vite-plugin-dts';
export default defineConfig({
plugins: [
dts({
// Emit a single rolled-up declaration file next to the bundle.
rollupTypes: true,
// Only include library source, never test or story files.
include: ['src'],
exclude: ['src/**/*.test.ts', 'src/**/*.stories.tsx'],
tsconfigPath: './tsconfig.build.json',
}),
],
build: {
// Keep readable output; consumers minify downstream.
minify: false,
sourcemap: true,
// Do not wipe sibling artifacts (e.g. types) between format passes.
emptyOutDir: true,
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'MyLib', // required for the umd global
formats: ['es', 'cjs', 'umd'],
// Name each artifact deterministically per format.
fileName: (format) => {
if (format === 'es') return 'index.mjs';
if (format === 'cjs') return 'index.cjs';
return `index.${format}.js`;
},
},
rollupOptions: {
// Never bundle peers; consumers provide them.
external: ['react', 'react-dom', 'react/jsx-runtime'],
output: {
// Required for the umd build to resolve externals at runtime.
globals: {
react: 'React',
'react-dom': 'ReactDOM',
'react/jsx-runtime': 'jsxRuntime',
},
// Keep emitted CSS at a stable, referenceable name.
assetFileNames: 'style[extname]',
},
},
},
});
Run the build and inspect output with the standard CLI:
# Build all configured formats
npx vite build
# List emitted artifacts to confirm es/cjs/umd + types + css
ls -1 dist/
# dist/index.mjs dist/index.cjs dist/index.umd.js dist/index.d.ts dist/style.css
The corresponding package.json must point each consumer condition at the right artifact. The exports map is the authoritative resolver in Node 12+ and modern bundlers; main/module/types remain as fallbacks for older tooling.
// package.json — exports map is load-bearing
{
"name": "my-lib",
"version": "1.0.0",
"type": "module",
"files": ["dist"],
"sideEffects": ["**/*.css"],
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./style.css": "./dist/style.css"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
}
The "types" condition must appear first inside each exports block — Node and TypeScript match conditions top-to-bottom, and a types entry placed after import/require is silently skipped. The "sideEffects" array marks CSS as load-bearing so consumers’ tree-shakers do not drop your stylesheet import.
Numbered Workflow
- Scaffold the entry. Create
src/index.tsthat re-exports the public API only. Anything not exported here is private and gets tree-shaken from consumers. - Move framework packages to
peerDependencies. Runnpm pkg delete dependencies.reactand addreactunderpeerDependencies, then list it inrollupOptions.external. Verify withnpm ls react. - Write
vite.config.tsusing the reference above. ConfirmformatsandfileNameproduce the names yourexportsmap references. - Add
vite-plugin-dtsand a build-specifictsconfig.build.jsonwith"declaration": true,"emitDeclarationOnly": true, and"noEmit": false. - Build:
npx vite build. Confirm the exact file set withls -1 dist/. - Wire
package.jsonexports/main/module/typesto the emitted filenames. Keeptypesfirst in each condition block. - Validate resolution with
npx publintandnpx @arethetypeswrong/cli --pack. Both report brokenexportsconditions and unresolvabletypesbefore you publish. - Smoke-test consumption:
npm pack, thennpm install ./my-lib-1.0.0.tgzin a throwaway app and import from both an ESM and a CJS file.
Debugging & Failure Modes
Peer dependency bundled into the output
Symptom: dist/index.mjs contains React’s source; consumers ship two React copies and hit Invalid hook call or Cannot read 'useState' of null. Cause: the package is missing from rollupOptions.external, or external is an exact string that does not match a sub-path import like react/jsx-runtime. Fix: switch to a regex or peerDependencies-derived external (see the externalizing peer dependencies guide) and grep the output: grep -l "function useState" dist/*.mjs should return nothing.
Missing or wrong type declarations
Symptom: consumers see Could not find a declaration file for module 'my-lib' or get any. Causes: vite-plugin-dts not in plugins, include excluding the entry, or tsconfig with "declaration": false. Fix: confirm dist/index.d.ts exists after build and that exports["."].types resolves to it. Run npx @arethetypeswrong/cli --pack to detect a types-after-import ordering bug, which surfaces as false ESM / Masquerading as CJS.
Broken exports map
Symptom: Package subpath './foo' is not defined by "exports" or require() resolving to the ESM file and crashing with Unexpected token 'export'. Cause: an exports key referencing a path Vite never emitted, or require pointing at .mjs. Fix: every exports target must match a real file in dist/; run node -e "require('./dist/index.cjs')" and node --input-type=module -e "import('./dist/index.mjs')" to exercise both conditions. publint flags these mechanically.
UMD build fails with undefined globals
Symptom: the UMD bundle throws React is not defined at runtime on a CDN. Cause: an external listed in rollupOptions.external has no matching key in output.globals. Fix: every external must appear in globals, including sub-paths like react/jsx-runtime.
Performance
build.lib builds are fast because they skip HTML processing and (with minify: false) skip terser. The dominant cost is vite-plugin-dts, which runs the full TypeScript compiler; on large libraries set rollupTypes: false to skip the API-Extractor rollup pass if you do not need a single declaration file. preserveModules: true trades build-time inlining for consumer-side tree-shaking — measure the consuming app’s bundle, not your library’s. For libraries with many small entry points, the tree-shaking mechanics of the downstream bundler matter more than your own output size, so prioritise a clean sideEffects flag and preserved ESM over aggressive pre-minification.
Compatibility Matrix
| Capability | Vite 4.x | Vite 5.x | Vite 6.x |
|---|---|---|---|
build.lib.entry (multiple entries) |
Yes | Yes | Yes |
lib.fileName(format, entryName) 2nd arg |
4.0+ | Yes | Yes |
cssCodeSplit default in lib mode |
off | off | off |
| Bundled Rollup | 3.x | 4.x | 4.x |
vite-plugin-dts major |
3.x | 4.x | 4.x |
| Node floor | 14.18+ | 18.18+ | 18.18 / 20.19+ |
Related
- Vite Configuration & Ecosystem — the parent overview covering dev server, plugins, env modes, and SSR that this library workflow extends.
- Externalizing peer dependencies in Vite library mode — a regex/
peerDependencies-drivenexternalandoutput.globals, with bundle verification. - Vite version compatibility reference — which Vite/Node/Rollup/plugin versions to pin for a library build.
- Tree-shaking mechanics and dead code elimination — why
sideEffectsand preserved ESM determine downstream output size. - Understanding ESM vs CommonJS in modern bundlers — the dual-format model behind
import/requireconditions.