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.

Vite library build producing multiple output formats plus type declarations A single source entry flows through build.lib into ES, CommonJS, and UMD bundles, with vite-plugin-dts emitting declaration files, all wired by the package.json exports map. src/index.ts library entry build.lib Rollup pipeline index.mjs (es) tree-shakable index.cjs (cjs) require() fallback index.umd.js CDN global index.d.ts vite-plugin-dts package.json "exports" map import / require / types conditions
Figure: one source entry fans out into es/cjs/umd artifacts plus declarations, all addressed by the conditional exports map.

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.4 or vite@^6.0). build.lib has been stable since Vite 2, but the lib.fileName callback 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@^4 for declaration generation, plus a tsconfig.json with "declaration": true and a configured include.
  • A package.json with "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. reactReact). 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

  1. Scaffold the entry. Create src/index.ts that re-exports the public API only. Anything not exported here is private and gets tree-shaken from consumers.
  2. Move framework packages to peerDependencies. Run npm pkg delete dependencies.react and add react under peerDependencies, then list it in rollupOptions.external. Verify with npm ls react.
  3. Write vite.config.ts using the reference above. Confirm formats and fileName produce the names your exports map references.
  4. Add vite-plugin-dts and a build-specific tsconfig.build.json with "declaration": true, "emitDeclarationOnly": true, and "noEmit": false.
  5. Build: npx vite build. Confirm the exact file set with ls -1 dist/.
  6. Wire package.json exports/main/module/types to the emitted filenames. Keep types first in each condition block.
  7. Validate resolution with npx publint and npx @arethetypeswrong/cli --pack. Both report broken exports conditions and unresolvable types before you publish.
  8. Smoke-test consumption: npm pack, then npm install ./my-lib-1.0.0.tgz in 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+

In-Depth Guides