Choosing Vite Library Mode Output Formats: ESM, CJS, UMD Trade-offs and Validation
Decide whether to ship ESM, CJS, UMD, or a combination from Vite library mode, see the trade-offs in a compact table, and follow a concrete build-and-validation workflow.
13 Nov 2025, 11:16 UTC

Decision: Which output formats should Vite library mode produce for your npm package?
When you author a reusable library with Vite's build.lib feature, you must decide which JavaScript formats to ship. The choice affects bundle size, tree-shaking ability, compatibility with older tooling, and the risk of duplicate copies (the dual-package hazard). This guide walks through the decision constraints, compares the three supported strategies in a compact table, explains the trade-offs, and then shows a concrete Vite configuration plus validation steps you can run locally.
Constraints to consider
- Consumer environments: Will users import via modern ES modules (
import), CommonJS (require), or load the library from a<script>tag? - Tree-shaking: Do you want bundlers to eliminate unused exports?
- Build time and asset size: Are you willing to pay the cost of multiple builds for broader compatibility?
- Global namespace safety: If you ship a UMD build, can you guarantee a unique
windowproperty?
Supported format strategies
| Strategy | Outputs | Typical use case | Key pros | Key cons |
|---|---|---|---|---|
| ESM-only | dist/index.es.js (or similar) | Modern bundlers, Vite, Webpack 5+, ES-module-aware Node | Smallest bundle, full tree-shaking, no duplicate-package hazard | Fails in legacy Node (require) or older bundlers that expect CJS |
| Dual ESM + CJS | dist/index.es.js and dist/index.cjs.js | Maximum compatibility; consumers can pick the format that matches their toolchain | Works with both import and require, still tree-shakable for ESM consumers | Double build time, larger dist folder, risk of dual-package hazard if exports map is misconfigured |
| UMD (global) | dist/index.umd.js | Legacy browsers, direct <script> inclusion, environments without a bundler | Runs in any script tag, no build step required for the consumer | No tree-shaking, creates a global variable (namespace pollution risk), larger than ESM due to UMD wrapper |
Trade-off explanation
If your library targets only modern toolchains (e.g., Vite, Remix, Next.js), the ESM-only build gives the smallest download and lets bundlers drop unused code. Adding a CJS build extends support to older Node versions and older Webpack releases without sacrificing ESM tree-shaking for those who can use it. The UMD format is only necessary when you anticipate consumers loading the library via a plain <script> tag or when you need to support environments that cannot resolve ES module specifiers. Each additional format increases CI time and the surface area for configuration errors, especially around the exports field in package.json.
Concrete Vite library mode configuration
Below is a minimal vite.config.ts that produces ESM + CJS outputs, leaves peer dependencies external, and extracts CSS into a separate file. Adjust the placeholders ({{PACKAGE_NAME}}, {{ENTRY}}) to match your project.
import { defineConfig } from 'vite';
export default defineConfig({
build: {
lib: {
// Entry point of your library (relative to project root)
entry: 'src/{{ENTRY}}.ts',
// Name used for UMD globals; only needed if you include 'umd' in formats
name: '{{PACKAGE_NAME}}',
// File name pattern; [format] will be replaced by es, cjs, or umd
fileName: (format) => `index.${format}.js`,
// Choose the formats you want to ship
formats: ['es', 'cjs'],
},
// Do not bundle peer dependencies; they must be listed in package.json peerDependencies
rollupOptions: {
external: ['react', 'lodash'], // example peer deps
},
// CSS extraction: one CSS file per entry point
cssCodeSplit: true,
},
});
Run the build from the project root (requires read/write access to the dist folder):
# Install dev dependencies if not present
npm i -D vite typescript
# Production build
npm run build # assumes "build": "vite build" in package.json
After the build, inspect the dist directory. You should see index.es.js, index.cjs.js, and a companion CSS file (e.g., index.css) if your source imports styles.
Validation checklist
- Package contents: Run
npm pack --dry-run(orpnpm pack --dry-run) to list what would be published. Verify that only the expected files appear and that peer dependencies are absent. - TypeScript declarations: If you use
vite-plugin-dts, ensuredist/index.d.tsexists and matches the exported API. - ESM consumption test: In a fresh folder, run
npm link ../your-lib, createtest.mjswithimport { foo } from 'your-lib', and execute it with Node. Confirm no errors and that the expected value is returned. - CJS consumption test: Create
test.cjswithconst { foo } = require('your-lib')and runnode test.cjs. Verify the same result. - Dual-package hazard check: Install
attw(npm i -D attw) and runnpx attw --pack .on the packed tarball. The tool will warn if theexportsmap could lead to two copies of the library. - Bundle size inspection: Use
npx rollup-plugin-visualizer dist/index.es.js --opento confirm that peer dependencies appear as external blocks and are not bundled.
Limitations and practical verification
Vite library mode does not automatically transpile modern syntax (e.g., top-level await, import.meta) for older Node versions. If you need to support older Node releases, set build.target appropriately or run a separate transpile step after vite build. CSS extraction merges all styles from a single entry into one file; if you need per-component CSS, consider multiple entries or a post-build step that splits the file. The UMD global name must be a valid identifier and unique on window; a safe pattern is to prefix with your package name, e.g., name: 'MyLib_{{PACKAGE_NAME}}'.
To check that the UMD global works, serve the dist folder with npx serve dist, open the served page in a browser console, and verify that window.MyLib (or your chosen name) exposes the expected API.
When to consider raw Rollup
If you need non-standard output targets (Cloudflare Workers, React Native), advanced code-splitting that creates multiple chunks, or fine-grained control over the Rollup plugin pipeline, Vite's opinionated defaults may become a hindrance. In those cases, migrate to a custom Rollup configuration while keeping the same external and output options you used in Vite.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.