Overview
Vite is known as a dev server for apps, but it's also a solid build tool for npm packages. The library mode is less documented than the app workflow, and the defaults aren't quite right for the common case. Here's what I've landed on after publishing a few packages with it.
What's different about building a library
| App build | Library build |
|---|---|
| Bundle everything | Mark dependencies as external |
| One output format (ESM) | ESM + CJS for compatibility |
| Optimize for size | Preserve types, exports, and tree-shaking |
| Assets inlined or hashed | CSS extracted, assets referenced |
The core difference is that consumers of your library have their own bundler. You don't bundle React into your package — you tell your build to leave it external and declare it as a peer dependency.
The project structure
my-package/
├── src/
│ ├── index.ts
│ ├── Button.tsx
│ └── useThing.ts
├── dist/ (generated)
├── package.json
├── tsconfig.json
└── vite.config.ts
vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import dts from "vite-plugin-dts";
import { resolve } from "path";
export default defineConfig({
plugins: [
react(),
dts({
insertTypesEntry: true,
rollupTypes: true,
}),
],
build: {
lib: {
entry: resolve(__dirname, "src/index.ts"),
name: "MyPackage",
formats: ["es", "cjs"],
fileName: (format) => `index.${format === "es" ? "mjs" : "cjs"}`,
},
rollupOptions: {
external: [
"react",
"react-dom",
"react/jsx-runtime",
],
output: {
globals: {
react: "React",
"react-dom": "ReactDOM",
},
},
},
sourcemap: true,
minify: false,
},
});
A few decisions worth explaining.
Why react/jsx-runtime is external
If you're on the automatic JSX runtime (React 17+), your compiled output imports from react/jsx-runtime rather than referencing a global React. If you don't mark it external, Vite bundles part of React into your package. Then consumers have two copies of React's runtime, and hooks break with cryptic errors.
Why both ESM and CJS
ESM is the future and what modern bundlers expect. CJS is still needed for Node scripts and older tooling. Publishing both costs nothing and prevents a class of "it doesn't work in Jest" issues.
Why minify: false
Libraries should not be minified. The consumer's bundler will minify the final app, and minifying in your package makes stack traces unreadable for people debugging your code.
Why the type generation plugin
Without it, your TypeScript types don't get emitted to dist/, and consumers get any for everything. The rollupTypes option bundles all your declarations into a single .d.ts file, which is faster for TypeScript to process than hundreds of small files.
npm install -D vite @vitejs/plugin-react vite-plugin-dts typescript
package.json exports
The exports field is what makes both formats work correctly:
{
"name": "@yourscope/my-package",
"version": "1.0.0",
"type": "module",
"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"
},
"./styles.css": "./dist/style.css"
},
"files": ["dist"],
"sideEffects": ["**/*.css"],
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"scripts": {
"build": "vite build",
"dev": "vite build --watch"
}
}
Three fields people get wrong:
files— controls what gets published. Always include it, or you'll publish your source, tests, and config along with the build.sideEffects— tells bundlers which files are safe to tree-shake. CSS files have side effects (they inject styles); everything else doesn't. Getting this right is what lets consumers import one component and get one component's worth of code.peerDependencies— React, Vue, or any framework you're building against goes here, not independencies. Otherwise you force a specific version on consumers.
Handling CSS
If your library ships styles, you have two options. The simple one is extracting to a separate file the consumer imports:
import "@yourscope/my-package/styles.css";
The other option is CSS-in-JS or CSS modules, which requires more setup and is less predictable for consumers. I default to the extracted file.
One thing that surprises people: Vite will emit style.css in dist/ automatically if any component imports a .css file. You just need to expose it in exports, which the config above does.
Development workflow
npm run dev
That's vite build --watch. Every source change rebuilds. If you're developing the library alongside an app that consumes it, the cleanest setup is npm workspaces with a symlink:
// app/package.json
{
"dependencies": {
"@yourscope/my-package": "workspace:*"
}
}
Then npm run dev in the library and npm run dev in the app, and the app picks up changes on rebuild. Slightly slower than HMR, but it catches packaging issues that a linked dev server would hide.
Testing the published package before publishing
The most common failure mode is "works locally, broken when installed." Test it properly:
# In the library
npm run build
npm pack
# produces my-package-1.0.0.tgz
# In a test app
npm install ../my-package/my-package-1.0.0.tgz
npm pack produces the exact tarball that would be published. Installing that tarball exercises the real resolution path — the exports field, the files list, the peer dependencies. If something's wrong with your packaging config, you find out here instead of after publishing.
What to check before npm publish
| Check | How |
|---|---|
Only dist/ is included | tar -tzf my-package-1.0.0.tgz |
| Types resolve | Import in a TS project, hover a symbol |
| Tree-shaking works | Import one function, check bundle size |
| Both formats load | Test in an ESM and a CJS project |
| Peer deps aren't bundled | grep -r "react" dist/ should be minimal |
| Sourcemaps point to source | Click through a stack trace in devtools |
When Vite is overkill
If you're publishing a single function with no dependencies, tsc alone is simpler. Vite's value is in handling multiple formats, CSS, and the dev server. For a pure TypeScript utility library, tsc --emitDeclarationOnly plus a small build script is fewer moving parts.
For anything with components, styles, or multiple entry points, the config above is a reasonable starting point that I've reused across four packages now without significant changes.
