Vite Library Mode: Architecture Decisions for Publishing npm Packages
Architecture note on Vite library mode: configuring build.lib for npm packages, externalizing peer dependencies, handling CSS extraction, and verifying the published artifact before consumers adopt it.
11 Jul 2025, 16:05 UTC

The Problem: Shipping Library Code Without Shipping Your Dependencies
When you build a library for npm, the artifact must expose your API while leaving peer dependencies as external imports. Vite's build.lib mode handles this, but the defaults favor application bundling — unminified output, CSS extraction, and a dev server that doesn't reflect the published bundle. If you treat library builds like app builds, you'll publish bloated packages, miss tree-shaking opportunities, or ship CSS that collides with consumer design systems.
Requirements That Shape the Design
- Consumer-controlled dependencies: Peer dependencies (React, Vue, lodash) must remain as
importstatements so the consumer's bundler deduplicates them. - Format alignment: Modern consumers expect ES modules (
es); UMD is legacy and may be dropped in future Vite majors. - CSS isolation: Library styles should be opt-in, not injected globally.
- Reproducible output: The published tarball must match what
vite buildproduces, without dev-only transforms.
Smallest Suitable Configuration
Start with a minimal vite.config.ts that declares the entry, output name, and externalizes peers:
import { defineConfig } from 'vite'
import { resolve } from 'path'
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'MyLib',
formats: ['es'],
fileName: (format) => `my-lib.${format}.js`
},
rollupOptions: {
external: ['react', 'react-dom', 'lodash-es'],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
'lodash-es': '_'
}
}
}
}
})
This produces dist/my-lib.es.js with import React from 'react' preserved. No UMD, no minification, no CSS in JS.
Trust and Data Boundaries
What the Library Controls
- Its own source tree and internal dependencies
- Exported API surface (types + runtime)
- Build-time transforms via Rollup plugins
What the Consumer Controls
- Which peer dependency versions get installed
- How CSS is imported (or ignored)
- Whether the bundle gets further minified or transpiled
Crossing these boundaries causes runtime failures. Over-externalizing (e.g., marking a direct dependency as external) leaves missing imports. Under-externalizing bundles peer code, breaking deduplication and potentially shipping duplicate React copies.
CSS Handling: Explicit Opt-In
When your entry imports ./styles.css, Vite extracts it to dist/my-lib.es.css. The JS bundle contains no styles. Consumers must import the CSS themselves:
import 'my-lib/my-lib.es.css'
import { MyComponent } from 'my-lib'
This prevents global leakage. If you need CSS-in-JS or inline styles, configure a Rollup plugin (e.g., vite-plugin-css-injected-by-js) in build.rollupOptions.plugins — but document the trade-off: consumers lose the ability to tree-shake unused styles.
Operational Checks Before Publishing
- Run the build and inspect output:
Run in the package root. Requires write access tonpm run build ls -la dist/ # Expect: my-lib.es.js, my-lib.es.css (if CSS imported)dist/. Verify file sizes are reasonable (tens of KB, not MB). - Check external imports survive:
Run in package root. Confirmsgrep -n "import.*from 'react'" dist/my-lib.es.js # Should show preserved import statementsexternalconfiguration worked. - Smoke-test with
npm pack:
Run in package root. No special permissions. Verifynpm pack --dry-run # Lists files that would be published npm pack # Creates my-lib-1.0.0.tgzpackage.jsonexportsormain/modulefields point to the correct dist files. - Install tarball in a test consumer:
Run in a clean directory. Requires Node with ES module support (Node 14+). Confirms runtime resolution and no missing peer errors.cd /tmp/test-consumer npm init -y npm install /path/to/my-lib-1.0.0.tgz # Write a minimal import test node -e "import('my-lib').then(m => console.log(Object.keys(m)))"
Failure Modes and Mitigations
| Failure Mode | Symptom | Mitigation |
|---|---|---|
| Missing peer at runtime | Error: Cannot find module 'react' in consumer | Add peer to peerDependencies in package.json; ensure external matches |
| Duplicate peer in bundle | Two React copies, hooks errors, bundle size spike | Audit rollupOptions.external; run npm ls react in consumer |
| CSS collision | Consumer styles broken by library globals | Extract CSS (default); avoid :root or broad selectors; document CSS import requirement |
| Unminified production bundle | Large download for consumers | Enable build.minify: 'esbuild' (or 'terser') in library config |
| Dev server used as smoke test | npm run preview serves SPA fallback, not library | Never use preview for library validation; use npm pack flow above |
Conditions That Would Change This Design
- Legacy consumer requirement: If a major consumer cannot use ES modules, add
'umd'toformatsand setnamefor the global. Mark UMD as deprecated in docs. - CSS-in-JS migration: If the library adopts a runtime CSS solution (e.g., Panda CSS, vanilla-extract), remove CSS entry imports and configure the corresponding Vite plugin in
build.rollupOptions.plugins. - Monorepo internal packages: For packages never published to npm, you may skip
externaland let the root bundler handle deduplication — but keep the config explicit so the boundary is visible. - SSR entry needed: Library mode does not produce server entry points. Add a separate
build.ssrconfiguration or a second Vite config for the server bundle.
Limitations
- Vite library mode does not generate TypeScript declaration files. Pair with
tsc --declaration --emitDeclarationOnlyorvite-plugin-dts. - No built-in version injection. Use a Rollup plugin or
defineto embed__VERSION__at build time. npm run previewserves theindex.htmlSPA fallback, not the library bundle. It cannot validate library exports.- UMD output depends on
namefor the global variable; changing it is a breaking change for any UMD consumer.
Verification Checklist
Before every publish:
vite buildcompletes without warningsdist/contains only declared entry formats- External imports match
peerDependenciesexactly npm packtarball installs and imports in a clean consumer- Bundle size is within budget (compare
gzip-size dist/my-lib.es.js)
If any check fails, fix the config — do not publish and hope consumers work around it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.