Back to core workflows Fix dependency resolution Tune package metadata Validate before publishing

Building a Library with Vite Library Mode

Teams that already use Vite for applications often want the same tool for their component libraries, and Vite's library mode delivers: one configuration builds ESM and CommonJS bundles, CSS and assets, with the plugin ecosystem you already know. The defaults, though, are tuned for applications, and a library built without adjusting them ships React inside the bundle, loses its type declarations, or produces a single file that defeats tree-shaking. This guide configures library mode correctly for a published package, including externals, declarations, CSS and the package.json that exposes it all.

When Vite library mode fits

Library mode is a good choice for UI component libraries, packages that ship CSS or static assets, and teams that want one bundler across applications and libraries. For small, pure-TypeScript utility packages, tsup or plain tsc are simpler and faster; the trade-offs across bundlers are covered in Bundling and Build Tooling for Libraries.

The problems that bring people here look like this:

# Consumer's app
Warning: Invalid hook call. Hooks can only be called inside of the body of a function component.
  1. You might have mismatching versions of React and the renderer (such as React DOM)
  2. You might be breaking the Rules of Hooks
  3. You might have more than one copy of React in the same app
# Consumer's TypeScript
error TS7016: Could not find a declaration file for module '@acme/ui'.

The first means React was bundled into the library; the second means no declarations were emitted — Vite does not emit them by default.

Configuring library mode

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import { resolve } from 'node:path';
import pkg from './package.json' with { type: 'json' };

const external = [
  ...Object.keys(pkg.peerDependencies ?? {}),
  ...Object.keys(pkg.dependencies ?? {}),
  /^react\//,          // react/jsx-runtime and other subpaths
  /^node:/,
];

export default defineConfig({
  plugins: [
    react(),
    dts({ include: ['src'], exclude: ['**/*.test.tsx', '**/*.stories.tsx'] }),
  ],
  build: {
    lib: {
      entry: {
        index: resolve(import.meta.dirname, 'src/index.ts'),
        hooks: resolve(import.meta.dirname, 'src/hooks/index.ts'),
      },
      formats: ['es', 'cjs'],
      fileName: (format, name) => `${name}.${format === 'es' ? 'js' : 'cjs'}`,
    },
    rollupOptions: { external },
    sourcemap: true,
    emptyOutDir: true,
    cssCodeSplit: false,
  },
});

Each option addresses a specific library concern:

  • build.lib.entry as an object gives each public entry point its own output file, matching subpath exports.
  • formats: ['es', 'cjs'] produces both module formats; drop cjs for an ESM-only package.
  • rollupOptions.external is the most important setting. Every peerDependency and dependency must be external, including subpaths such as react/jsx-runtime, which a plain string 'react' does not match. Anything not external is copied into your bundle.
  • vite-plugin-dts emits .d.ts files next to the output.
  • cssCodeSplit: false emits a single style.css for the library rather than per-chunk CSS.
What Vite library mode does with each import Source imports are classified as external or bundled; externals stay as imports in the output while internal modules are bundled into ES and CJS files with declarations and CSS alongside. src/index.ts imports react, clsx, ./Button external check peers and deps stay as imports Rollup bundle internal modules only dist/ index.js, index.cjs, .d.ts, style.css
Every dependency and peer dependency must be classified as external, or it is copied into the library output.

The package.json that exposes the build

{
  "name": "@acme/ui",
  "version": "2.0.0",
  "type": "module",
  "files": ["dist"],
  "sideEffects": ["**/*.css"],
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./hooks": {
      "types": "./dist/hooks/index.d.ts",
      "import": "./dist/hooks.js",
      "require": "./dist/hooks.cjs"
    },
    "./style.css": "./dist/style.css",
    "./package.json": "./package.json"
  },
  "peerDependencies": {
    "react": "^18.2.0 || ^19.0.0",
    "react-dom": "^18.2.0 || ^19.0.0"
  },
  "dependencies": {
    "clsx": "^2.1.1"
  },
  "devDependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "vite": "^6.0.0",
    "vite-plugin-dts": "^4.3.0"
  }
}

The sideEffects field tells consumers' bundlers that only CSS files have side effects, so unused components can be dropped. Exporting ./style.css lets consumers import the stylesheet explicitly. The types paths must match what vite-plugin-dts actually emits — check the dist folder after the first build rather than guessing, because the plugin mirrors your source structure. For CommonJS consumers under node16 resolution you also need .d.cts files; see Fixing 'Masquerading as CJS' Type Errors.

Library mode defaults versus library-ready settings Compares Vite's default library build with the adjusted configuration on externals, declarations, CSS, entry points and tree-shaking. Vite defaults Library-ready config Dependencies bundled in external (incl. subpaths) Type declarations none vite-plugin-dts CSS injected per chunk one exported style.css Entry points single entry one per public subpath Tree-shaking for consumers one large file entries + sideEffects
Most library-mode bugs come from four defaults that suit applications but not packages.

Preserving modules for better tree-shaking

A single bundled file per entry works, but consumers' bundlers tree-shake better when each component is its own module. Rollup's preserveModules keeps the source file structure in the output:

rollupOptions: {
  external,
  output: {
    preserveModules: true,
    preserveModulesRoot: 'src',
  },
},

With this, dist/Button/Button.js and dist/Dialog/Dialog.js are separate files, and a consumer importing only Button never parses Dialog. The approach and its trade-offs are covered in Preserving Modules for Tree-Shaking with Rollup.

CSS, assets and fonts

Component libraries rarely ship JavaScript alone, and library mode's handling of non-JavaScript files is where application habits cause the most surprises.

CSS. With cssCodeSplit: false, every CSS import in your components is collected into one dist/style.css. Consumers must import it once. With CSS Modules, class names are hashed at build time and the mapping is inlined into the JavaScript, which works for consumers without any configuration. If you prefer per-component CSS so unused styles are dropped too, enable cssCodeSplit together with preserveModules, and mark CSS as a side effect in sideEffects so consumer bundlers keep the imports.

Images and SVGs. By default, Vite inlines assets under 4 KB as data URLs and emits larger ones into dist/assets with hashed names, rewriting imports to relative URLs. In a library that is usually what you want for small icons. For larger assets, the emitted relative URLs must resolve from the consumer's bundle, which works with every modern bundler because they treat new URL('./asset.png', import.meta.url) references as assets to copy. Set build.assetsInlineLimit deliberately rather than relying on the default.

Fonts. Shipping font files inside a component library couples every consumer to your loading strategy. Many design systems instead ship CSS that references fonts by name and document how applications should load them, keeping font delivery (preloading, subsetting, CDN choice) in the application's hands.

Output of a library-mode build with two entries The dist folder after building a component library with index and hooks entries in ES and CJS formats, declarations, a stylesheet and assets. dist/ index.js / index.cjs root entry, both formats hooks.js / hooks.cjs ./hooks subpath index.d.ts from vite-plugin-dts hooks/index.d.ts mirrors src structure style.css exported as ./style.css assets/logo-8f3a.svg above the inline limit
Every file referenced by the exports map must appear here; compare the tree with package.json after each build change.

Using library mode inside a monorepo

In a workspace, the same library is consumed two ways: by applications in the repository during development, and by external consumers after publishing. Building the library on every change slows local development, so many teams let applications consume the library's source directly in development — through a development condition in exports or a Vite alias — and use library mode only for the published build. The pattern is covered in Using Internal Packages Without a Build Step. If you do consume the built output locally, run vite build --watch for the library alongside the application's dev server, and make sure the task runner builds the library before the application in CI.

Worked example: removing a bundled React

A design system team publishes @acme/ui with external: ['react', 'react-dom']. Consumers report invalid hook call warnings. Inspecting dist/index.js shows a large block of code from react/jsx-runtime: the automatic JSX transform imports react/jsx-runtime, which does not match the string 'react' in the externals list, so Rollup bundled it — along with its own internal copy of React's dispatcher. Replacing the list with one derived from peerDependencies plus the regular expression /^react\// removes it. The team adds a CI check that greps the build output for react.production and jsx-runtime source to prevent a regression, and validates the packed tarball in a fixture application, as described in Smoke-Testing a Tarball with npm pack.

Validation commands

npx vite build

# Nothing from react should be inside the bundle
grep -l "react.production\|__SECRET_INTERNALS" dist/*.js && echo "React bundled!" || echo "react external"

# Declarations exist for every entry
ls dist/*.d.ts dist/hooks/*.d.ts

# Check the published surface
npx publint && npx @arethetypeswrong/cli --pack .

# Bundle size per entry
du -h dist/*.js dist/*.cjs

Prevention and CI/CD guardrails

  • Derive external from package.json so a new dependency is external automatically.
  • Match subpaths with regular expressions for every external package that has them.
  • Run publint and Are the Types Wrong on the packed output in CI.
  • Test in a real consumer application before release, including a production build.

Frequently Asked Questions

Should my library's dependencies be bundled or external? External. Consumers' package managers install your dependencies, and bundling them duplicates code and prevents deduplication. Bundle only code you own, or tiny dependencies you deliberately inline and remove from dependencies.

Why is my CSS not applied in the consumer app? Library mode extracts CSS to a separate file; it is not imported automatically. Document import '@acme/ui/style.css', or inject CSS from JavaScript with a plugin if you prefer zero-config usage at the cost of flexibility.

Can Vite library mode produce UMD bundles? Yes, with formats: ['umd'] and a name for the global. UMD is rarely needed for packages consumed through npm today; add it only for script-tag users.

How do I keep "use client" directives for React Server Components? Rollup strips module-level directives when it bundles files together, so a component library for the Next.js App Router loses its "use client" markers. Use preserveModules so each component stays its own file, and add a plugin that preserves directives (such as rollup-plugin-preserve-directives), then verify the directive is the first line of each emitted client component.

Is library mode fast enough for large libraries? Builds are Rollup-based and usually take seconds to a minute. For very large libraries, split the build per entry, cache it with your task runner, and skip declaration generation in watch mode during development.

Related

Bundling and Build Tooling for Libraries