Vue.js 97 🟢 Building Component Libraries with Vite Library Mode (vite build –lib)
A Vue component library is not an application. The default Vite build treats the project as an app: it reads index.html, bundles every dependency, and produces a deployable website. Library mode changes the build target from a deployable page to a publishable package. The build.lib option in vite.config.ts tells Vite to use the library entry point instead of index.html, to externalize peer dependencies like Vue instead of bundling them, and to emit formats that consumers can import without pulling in a second copy of the framework .
This chapter covers the build.lib configuration, the externalization rule that prevents duplicate framework instances, the type declaration generation that makes the library usable from TypeScript, and the package.json exports that define the public API.
Key point: A component library must not bundle Vue. If Vue is bundled into the library, the consumer application ends up with two Vue instances—one from the library and one from the app—which produces broken reactivity, failed component resolution, and “Invalid hook call” errors. The rollupOptions.external array is what prevents this .
Why library mode exists
The duplicate framework problem. In application mode, Vite bundles every import into the output. A library that imports ref from vue would include the Vue runtime in its dist output. A consumer application that also uses Vue would then have two copies: the library’s bundled copy and the app’s own copy. Vue’s reactivity relies on a single instance; two instances break provide/inject, global components, and the component tree itself .
The tree-shaking problem. An application build produces a single bundled file with no named exports. A consumer that imports one component from the library pulls in the entire bundle, because the bundler cannot see the individual exports. Library mode preserves the module structure and the named exports, so the consumer’s bundler can tree-shake unused components .
The format problem. An application build targets the browser. A library must target the consumer’s bundler (ES modules), older build tools (CommonJS), and CDN script tags (UMD). The build.lib.formats array controls which output formats are produced .
a. The build.lib Configuration
The build.lib object requires an entry field and optionally specifies the global name, formats, and file names. The entry is the library’s public API file—typically src/index.ts—which exports every component and composable that the library makes available .
// vite.config.ts
import { resolve } from 'node:path'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'MyUI',
formats: ['es', 'cjs'],
fileName: (format) => `my-ui.${format}.js`,
},
rollupOptions: {
external: ['vue'],
output: {
globals: { vue: 'Vue' },
},
},
},
})
The name field is required only when formats includes umd or iife. It sets the global variable name that the UMD build exposes to the browser . The fileName function receives the format and returns the output file name; a common convention is my-ui.es.js and my-ui.cjs.js.
The formats array controls the output. es is the ES module format for modern bundlers. cjs is CommonJS for Node.js and older tools. umd is for direct <script> usage. Most Vue component libraries ship es and cjs, and add umd only if CDN usage is a requirement .
b. Externalizing Vue and Peer Dependencies
The rollupOptions.external array tells the bundler which imports to leave as external references instead of bundling. For a Vue component library, vue must be in this array. If the library uses vue-router or pinia, those should be externalized as well if they are declared as peer dependencies .
rollupOptions: {
external: ['vue', 'vue-router'],
output: {
globals: {
vue: 'Vue',
'vue-router': 'VueRouter',
},
},
}
The globals object maps the external module names to the global variables that the UMD build expects. This is only relevant for the umd or iife formats; the ES and CJS builds use the module system’s own resolution.
The package.json should declare Vue as a peerDependency, not a dependency. A peer dependency tells the consumer that they must provide Vue themselves. The npm 7+ behavior auto-installs peer dependencies, but the library’s own bundle does not include Vue .
{
"peerDependencies": {
"vue": "^3.5.0"
}
}
c. Type Declarations and package.json Exports
A component library written in TypeScript must ship .d.ts files for consumers to get type checking. vite-plugin-dts generates these declarations from the .ts and .vue source files during the build .
// vite.config.ts
import dts from 'vite-plugin-dts'
export default defineConfig({
plugins: [
vue(),
dts({
include: ['src'],
rollupTypes: true,
}),
],
// ...
})
The rollupTypes: true option merges all declarations into a single file, which simplifies the package.json types field. Without it, the declarations follow the source structure and the types field must point to the entry declaration file .
The package.json exports field defines the public API. It maps the import specifier to the file for each format, and the types condition to the declaration file.
{
"name": "my-ui",
"type": "module",
"main": "./dist/my-ui.cjs.js",
"module": "./dist/my-ui.es.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/my-ui.es.js",
"require": "./dist/my-ui.cjs.js"
},
"./style.css": "./dist/my-ui.css"
},
"files": ["dist"],
"sideEffects": ["**/*.css"]
}
The sideEffects field tells the consumer’s bundler that CSS files must be preserved during tree-shaking. Without it, an import of my-ui/style.css might be dropped, and the library would render without styles .
Complete Example Session
// ============================================
// PART 1: LIBRARY ENTRY POINT
// ============================================
// src/index.ts
export { default as MyButton } from './components/MyButton.vue'
export { default as MyCard } from './components/MyCard.vue'
export { useMyComposable } from './composables/useMyComposable'
// ============================================
// PART 2: VITE CONFIGURATION
// ============================================
// vite.config.ts
import { resolve } from 'node:path'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'
export default defineConfig({
plugins: [
vue(),
dts({ include: ['src'], rollupTypes: true }),
],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'MyUI',
formats: ['es', 'cjs'],
fileName: (format) => `my-ui.${format}.js`,
},
rollupOptions: {
external: ['vue'],
output: {
globals: { vue: 'Vue' },
},
},
},
})
// ============================================
// PART 3: PACKAGE.JSON
// ============================================
{
"name": "my-ui",
"version": "1.0.0",
"type": "module",
"main": "./dist/my-ui.cjs.js",
"module": "./dist/my-ui.es.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/my-ui.es.js",
"require": "./dist/my-ui.cjs.js"
},
"./style.css": "./dist/my-ui.css"
},
"files": ["dist"],
"sideEffects": ["**/*.css"],
"peerDependencies": {
"vue": "^3.5.0"
}
}
// ============================================
// PART 4: BUILD
// ============================================
// pnpm build
// Output:
// dist/my-ui.es.js
// dist/my-ui.cjs.js
// dist/index.d.ts
// dist/my-ui.css
// ============================================
// PART 5: CONSUMER USAGE
// ============================================
// import { MyButton } from 'my-ui'
// import 'my-ui/style.css'
The five parts trace the entry point, the Vite configuration, the package manifest, the build output, and the consumer usage. The critical configuration is Part 2: the external: ['vue'] prevents the duplicate framework problem, and dts generates the declarations.
Quick Reference
build.lib Fields
| Field | Purpose |
|---|---|
entry | Library API entry point |
name | Global variable name for UMD/IIFE |
formats | Output formats (es, cjs, umd, iife) |
fileName | Output file name pattern |
Externalization
| Module | External | Peer Dependency |
|---|---|---|
vue | Yes | Yes |
vue-router | Yes, if used | Yes |
pinia | Yes, if used | Yes |
| Lodash utility | Depends | Optional |
package.json Fields
| Field | Purpose |
|---|---|
main | CJS entry point |
module | ESM entry point |
types | TypeScript declarations |
exports | Public API contract |
peerDependencies | Host packages |
sideEffects | Preserve CSS |
Best Practices
✅ Do This:
// Externalize Vue
rollupOptions: { external: ['vue'] } // ✅
// Declare Vue as peer dependency
"peerDependencies": { "vue": "^3.5.0" } // ✅
// Generate declarations with vite-plugin-dts
plugins: [dts({ include: ['src'] })] // ✅
// Mark CSS as side effect
"sideEffects": ["**/*.css"] // ✅
❌ Don’t Do This:
// Bundle Vue into the library
rollupOptions: {} // no external // ❌
// Use a single format only
"formats": ["es"] // misses CJS consumers // ❌
// Omit the exports field
// Consumers cannot resolve the entry point // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Duplicate Vue instance | Vue bundled into library | external: ['vue'] |
| Missing type declarations | vite-plugin-dts not configured | Add the plugin |
| CSS dropped by tree-shaking | sideEffects not set | "sideEffects": ["**/*.css"] |
| Consumer cannot import | exports field missing | Add the exports map |
| UMD build fails | name field missing | Set name for UMD |
Real-World Examples
1. Library Entry
export { default as Button } from './components/Button.vue'
2. External Vue
rollupOptions: { external: ['vue'] }
3. Peer Dependency
"peerDependencies": { "vue": "^3.5.0" }
4. Type Declarations
dts({ include: ['src'], rollupTypes: true })
5. Exports Map
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.es.js" } }
6. CSS Side Effect
"sideEffects": ["**/*.css"]
7. UMD Global
output: { globals: { vue: 'Vue' } }
8. Multiple Formats
formats: ['es', 'cjs']
9. File Name Pattern
fileName: (format) => `my-ui.${format}.js`
10. Consumer Import
import { Button } from 'my-ui'
Visual
Application Build vs. Library Build
┌──────────────────────────────────────────────────────────────┐
│ APPLICATION BUILD LIBRARY BUILD │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ index.html │ │ src/index.ts │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Bundle all deps │ │ Externalize Vue │ │
│ │ (including Vue) │ │ (peer dep) │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Single JS file │ │ ES + CJS + d.ts │ │
│ │ No named exports│ │ Named exports │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ The library does not bundle the framework. │
│ │
└──────────────────────────────────────────────────────────────┘
The Duplicate Vue Problem
┌──────────────────────────────────────────────────────────────┐
│ WITHOUT EXTERNAL │
│ │
│ my-ui bundle: includes Vue runtime │
│ app bundle: includes Vue runtime │
│ │ │
│ ▼ │
│ Two Vue instances → broken reactivity │
│ │
│ WITH EXTERNAL │
│ │
│ my-ui bundle: import { ref } from 'vue' (external) │
│ app bundle: provides Vue │
│ │ │
│ ▼ │
│ One Vue instance → correct │
│ │
└──────────────────────────────────────────────────────────────┘
Type Declaration Generation
┌──────────────────────────────────────────────────────────────┐
│ SOURCE OUTPUT │
│ │
│ src/index.ts dist/index.d.ts │
│ src/components/Button.vue (types merged) │
│ src/components/Card.vue │
│ │ │
│ ▼ │
│ vite-plugin-dts │
│ (rollupTypes: true) │
│ │
│ Consumers get TypeScript support for every export. │
│ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Library mode option | build.lib |
| Entry point | src/index.ts |
| Vue externalization | rollupOptions.external: ['vue'] |
| Vue dependency | peerDependencies |
| Type declarations | vite-plugin-dts |
| CSS preservation | sideEffects: ["**/*.css"] |
| Output formats | es, cjs (and umd if needed) |
| Public API contract | package.json exports |
Key takeaways:
- Library mode changes the build target from an application to a package. The
build.liboption replacesindex.htmlwith the library entry point, and the output is a set of importable modules rather than a deployable website . - Vue must be externalized. The
rollupOptions.external: ['vue']array tells Vite not to bundle Vue into the library. Without it, the consumer ends up with two Vue instances, which breaks reactivity and component resolution . - Vue is a peer dependency, not a dependency. The library expects the consumer to provide Vue. The
peerDependenciesfield declares this expectation, and the consumer’s package manager handles the installation . vite-plugin-dtsgenerates the type declarations. The plugin produces.d.tsfiles from the.tsand.vuesources. TherollupTypes: trueoption merges them into a single file, which simplifies thetypesfield inpackage.json.- The
exportsfield defines the public API. It maps the import specifier to the ES module, the CommonJS module, and the type declaration. ThesideEffectsfield preserves CSS imports during tree-shaking .
Remember: A Vue component library is a package, not an application. The build must produce importable modules with named exports, externalize the framework, and ship type declarations. The build.lib configuration is the switch that changes Vite’s behavior from application bundling to library packaging. The external array is the rule that prevents the duplicate Vue instance. The exports field is the contract that tells the consumer how to import the library. When these three pieces are configured correctly, the library can be published to a registry, installed in an application, and tree-shaken like any other dependency.
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!