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.
The reason this problem exists at all is that the npm ecosystem never agreed on a single module format, so a published package has to satisfy several consumers simultaneously: a modern Vite or Webpack build that reads the import condition and wants live ESM to tree-shake, a Node script or Jest transform that calls require() and needs real CommonJS, and a TypeScript language server that reads the types condition to resolve declarations before any code runs. None of these consumers see your vite.config.ts. They see only the files in your published tarball and the package.json that addresses them. Every decision in library mode is therefore about producing a stable, self-describing artifact rather than a fast-loading page, and the failure surface is almost entirely at install and resolve time in someone else’s repository rather than in your own build logs.
The place this sits in the build pipeline is one layer below application bundling and one layer above the registry. Your source graph is compiled and formatted by Rollup into a small number of entry files; a separate TypeScript pass emits declarations alongside them; package.json conditions map consumer intent onto those files; and the tarball produced by npm pack is the boundary object that everything downstream reads. Get any one of those four layers wrong and the package still builds cleanly on your machine — the breakage only appears when a consumer with a different module system, a different bundler, or a stricter tsconfig tries to import it. That asymmetry is why the validation and smoke-test steps later in this guide are not optional polish; they are the only place the real consumer contract is exercised before publish.
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.
The mindset shift is that your build output is input to another build, not a finished artifact. Everything an application build optimizes for — minification, hashing, inlining — is either redundant or actively harmful once a downstream bundler takes over, because it destroys the module boundaries that bundler needs to do its own job well.
Consider each default in turn. Content-hashed filenames (index-a1b2c3.js) are correct for an application because the HTML that references them is emitted in the same build and rewritten to match; a package has no such HTML, and a hashed name means your package.json exports map would have to change on every release, so a stable index.mjs is mandatory. Bundling dependencies is correct for an application because you want a single optimized graph for the browser; for a package it means the consumer’s copy of React and your embedded copy are two distinct module instances, and React’s internal useState dispatcher is stored on a shared module-level object, so hooks called from your components read a null dispatcher and throw Invalid hook call. Minification is correct for an application because the bytes ship to a browser; for a package it strips the identifier names and module structure a downstream tree-shaker relies on to prove code is unused, so you pay minification cost twice and shrink the consumer’s ability to drop your dead code. Each application default is not merely unnecessary in a package — it removes information the next build needs.
There is one further asymmetry worth naming: an application build fails loudly when it is wrong, because you run the app. A library build that inlines a peer, or ships declarations in the wrong condition order, produces a green build and a published version that only breaks in consumer repositories you never see. The whole discipline of library mode is trading the comfort of a passing local build for a set of explicit checks that reproduce the consumer’s resolver.
build.lib flips every application-build default because your output is another build's input.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
Note that all three tools are devDependencies, never dependencies. A published package must not force its build toolchain onto consumers; the only runtime relationship a consumer has with your build is the emitted files. The one exception is any genuine runtime library your code actually imports and does not externalize — those belong in dependencies so the consumer’s install pulls them transitively. Framework packages you render against but do not want duplicated (React, Vue, the Vue compiler runtime) belong in peerDependencies, which is the declaration that pairs with rollupOptions.external: peerDependencies tells npm “the consumer must already have this,” and external tells Rollup “so do not bundle it.” Keeping those two lists in sync by hand is the single most common source of drift, which is why step two of the workflow derives one from the other.
The Node floor matters more than it looks. Vite 6 raising the minimum to 18.18/20.19 is not cosmetic: the exports resolver, import.meta.resolve, and the require(esm) interop that lets a CommonJS consumer pull an ESM-only package all changed behavior across those releases. Building a library on a newer Node than your minimum supported consumer can produce output that resolves on your machine and fails on theirs, so pin the CI Node version to the oldest release you claim to support rather than whatever is latest.
Core Mechanics
Four mechanics do all the work. They are independent — you can get externals right and still ship broken types — so it helps to hold them separately.
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.
Under the hood, Vite runs Rollup once with an array of output objects, not once per format. That matters because the module graph is analyzed a single time and then rendered into each format, so the es and cjs files describe the same tree with different import/require syntax and different helper prelude. The umd/iife constraint follows directly from what those formats are: a UMD wrapper is a single function that detects module.exports, AMD define, or a browser global and assigns one value, so it structurally cannot represent multiple entry chunks or code-split boundaries — there is nowhere for a second chunk to live. This is why a config that lists formats: ['es', 'umd'] alongside multiple entry keys will error: the es half can fan out into per-entry files but the umd half has no way to. When you need both a multi-entry ESM build and a single-file browser global, run two builds with different lib configs rather than trying to express both in one output array.
The default single-file es output is worth understanding as a deliberate choice rather than a limitation. With one entry and no preserveModules, Rollup concatenates the whole reachable graph into index.mjs, inlining internal modules and hoisting shared bindings. That produces the smallest possible file and the fewest network requests if a consumer somehow ships it unbundled, but it also fuses your internal modules so a downstream tree-shaker can only include or exclude the file as a whole minus whatever it can statically prove unused. For a small utility that is fine; for a component set where consumers import three of forty components, it is the wrong default, and preserveModules below is the fix.
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.
The resolution detail that trips people is how Rollup matches an external. A plain string entry is matched by exact specifier equality, not prefix: external: ['react'] keeps import 'react' out of the bundle but does nothing for import 'react/jsx-runtime', which the automatic JSX transform emits on every file. Rollup sees react/jsx-runtime as a different specifier, finds no match, resolves it through node_modules, and inlines it — silently shipping a second copy of the runtime. That is why production externals are almost always a regex (/^react($|\/)/) or a function that consults peerDependencies, so every sub-path of a peer is covered without enumerating them. The consequence of getting this wrong is not a build error; it is a package that works in your smoke test and duplicates React only in consumers who happen to import a component that uses JSX in a way that reaches the runtime import.
The output.globals map is a separate concern that only the umd/iife formats read, because those formats have no import/require — an external there becomes a reference to a property on the global object, and globals is the lookup table for which property. An external present in external but absent from globals produces a UMD file that references an undefined variable and throws at load. The es/cjs formats ignore globals entirely, which is why the same misconfiguration passes every ESM test and only surfaces when someone drops the UMD file onto a page via a script tag.
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.
The reason declarations are out-of-band is that Vite compiles TypeScript with esbuild, which transpiles each file in isolation and deletes type information rather than checking or emitting it — that is what makes Vite’s TS handling fast, and it is also why esbuild can never produce a .d.ts. Declaration emission requires the whole-program view that only tsc has, because a declaration file records the resolved type of every export including types imported from other modules and from node_modules. vite-plugin-dts therefore stands up a real TypeScript program using your tsconfig, walks the same entry, and writes declarations that mirror the shape of your JS output. Because it is a second pass with independent config, the two can disagree: esbuild might happily transpile a file that tsc refuses to emit declarations for, so a type error you never saw at runtime can fail the declaration pass and leave dist with JS but no .d.ts.
rollupTypes: true adds a further stage. After tsc emits one declaration file per source module, the plugin runs API-Extractor to flatten them into a single index.d.ts, inlining internal types and dropping ones that are not part of the public surface. This produces the cleanest consumer experience — one file, no dangling relative imports between declaration files — but it is the slowest part of the whole build and it is strict about what it can roll up. A default export of an anonymous type, or a type that references a non-exported symbol, can make API-Extractor emit warnings or an incomplete surface, so treat its output as something to validate, not assume.
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.
The mechanism is that preserveModules turns off Rollup’s chunk-merging: instead of concatenating the reachable graph into one output, each input module becomes its own output file with its imports rewritten to point at sibling output files. A consumer bundler then sees the same granular module boundaries you wrote, so when it imports my-lib/Button it can follow the import graph and include exactly the files Button reaches — nothing else. Without preserveModules, importing Button from a single-file bundle forces the consumer’s tree-shaker to reason about one large module and, in practice, it retains more than it needs because side-effect analysis across a fused file is conservative.
The costs are real and worth stating. preserveModules produces dozens or hundreds of small files, which inflates the tarball’s file count and can slow a consumer’s cold module resolution slightly. It also loses cross-module inlining, so a tiny internal helper that would have been folded into its caller now lives in its own file with its own import. And it interacts with sideEffects: if any preserved module has a top-level side effect and your package.json does not declare it, a consumer’s tree-shaker may drop it and change behavior. The rule of thumb is to enable preserveModules for component or utility sets where consumers import a subset, and leave it off for a single cohesive utility that is always imported whole. Because it emits many entry-like files, pair it with output.preserveModulesRoot: 'src' so the dist tree does not carry a redundant src/ prefix, and set entryFileNames: '[name].mjs' so every preserved file gets the .mjs extension your import condition expects.
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.
The resolution algorithm behind this is a first-match walk, not a best-match search. When a consumer writes import x from 'my-lib', the resolver collects the active conditions for that context — for an ESM import in a modern bundler that is roughly ['types', 'import', 'module', 'default'] — and walks the keys of your exports object in the order you wrote them, returning the first key that is in the active set. It does not reorder or prefer a more specific condition. That is the entire reason ordering is load-bearing: put require before import and an ESM consumer whose active set contains both still gets whatever comes first. The types condition is special only in that the TypeScript language server resolves it separately and equally positionally, so a types key sitting below import is unreachable for exactly the same first-match reason.
Note also what the legacy main/module/types triplet is for. Modern Node and every current bundler read exports and ignore those top-level fields entirely; they exist only for tools old enough to predate exports — older Webpack 4 setups, some bundler plugins, editors resolving without the exports code path. Keep them pointing at the same files as the corresponding exports conditions so the two resolvers never disagree, and treat exports as the source of truth. A package that sets main to the CJS build but omits a require condition will resolve differently depending on whether the consumer’s tool reads exports or falls back to main, which is exactly the kind of split-brain resolution publint exists to catch.
Multiple entry points
Component sets usually want more than one public path so consumers can import { Button } from 'my-lib/button' without pulling the whole surface. build.lib.entry accepts an object of named entries; each key becomes a subpath and each needs its own exports block. The config below emits a root entry plus a per-component entry, all as tree-shakable ESM/CJS with matching declarations.
// vite.config.ts — multiple named entries, Vite 6.x / Rollup 4.x
import { defineConfig } from 'vite';
import { resolve } from 'node:path';
import dts from 'vite-plugin-dts';
export default defineConfig({
plugins: [dts({ include: ['src'], tsconfigPath: './tsconfig.build.json' })],
build: {
minify: false,
sourcemap: true,
lib: {
// Object form: each key is a subpath consumers can import.
entry: {
index: resolve(__dirname, 'src/index.ts'),
button: resolve(__dirname, 'src/button/index.ts'),
},
// No umd here: multiple entries cannot collapse to one global.
formats: ['es', 'cjs'],
},
rollupOptions: {
external: [/^react($|\/)/, /^react-dom($|\/)/],
output: {
// [name] expands to the entry key (index, button).
entryFileNames: '[name].[format].js',
},
},
},
});
The matching exports map gives each entry its own conditional block. Every subpath a consumer can import must appear here; a subpath that resolves as a file on disk but is missing from exports throws is not defined by "exports" under Node’s strict resolver even though the file plainly exists.
// package.json — one exports block per emitted entry
{
"name": "my-lib",
"type": "module",
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.es.js",
"require": "./dist/index.cjs.js"
},
"./button": {
"types": "./dist/button.d.ts",
"import": "./dist/button.es.js",
"require": "./dist/button.cjs.js"
}
}
}
Multiple entries and preserveModules solve overlapping but distinct problems. Named entries define the public subpaths a consumer may address; preserveModules controls how granular the internal file graph behind each entry is. A component set often wants both — named entries so my-lib/button is importable, and preserved modules so the button entry does not drag in the rest of the tree — but you can adopt either independently.
CSS handling
A component library that ships styles has to decide where the CSS lives, because the consumer’s bundler treats a CSS import very differently from a JS import. In lib mode Vite disables cssCodeSplit and collects every import './x.css' reachable from the entry into one style.css next to the bundle, and it strips the import statements from the JS. That is a deliberate default: it means your JS output has no CSS side effects, so a consumer must explicitly import 'my-lib/style.css' to get your styles, and a consumer who supplies their own styling pays nothing.
The trade-off is that the styling is no longer automatic — forget to document the stylesheet import and consumers render unstyled components with no error. This is why the exports map above exposes ./style.css as an addressable subpath and the sideEffects array lists **/*.css: without the sideEffects entry, a consumer’s production tree-shaker sees import 'my-lib/style.css' as a statement with no used binding and drops it, silently removing the styles in production while they work in development. If you instead want styles to travel with the components automatically — common for CSS-in-JS or CSS Modules where each component owns its styles — keep cssCodeSplit off but ensure the emitted CSS import is preserved and declared as a side effect, and accept that consumers can no longer opt out of your stylesheet.
types condition after import/require never matches.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.
When not to use library mode
build.lib is the right tool for a package whose consumer is another build. It is the wrong tool in a few cases worth naming so you do not reach for it reflexively. If you are shipping a Node-only CLI or server that is never imported as a library, you want an application build with dependencies bundled or a plain tsc emit, not externalized peers and a UMD global — there is no downstream bundler to hand optimization to. If your package is pure TypeScript types with no runtime, skip Vite entirely and emit declarations with tsc; a bundler adds nothing. If you need per-file output with zero transformation — for example a set of framework components that a meta-framework compiler will process itself — tsc or an unbundled esbuild pass can be simpler than configuring preserveModules. And if the package is internal to a monorepo and only ever consumed through the workspace’s own bundler via source, you may not need a build step at all; exporting the TypeScript source and letting the consuming app compile it avoids the entire dual-format and declaration problem. Reach for library mode when you are publishing to a registry and cannot control how the artifact is consumed — that uncertainty is exactly what the multi-format, externalized, self-describing output buys you.
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.