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 buildLibrary build
Bundle everythingMark dependencies as external
One output format (ESM)ESM + CJS for compatibility
Optimize for sizePreserve types, exports, and tree-shaking
Assets inlined or hashedCSS 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 in dependencies. 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

CheckHow
Only dist/ is includedtar -tzf my-package-1.0.0.tgz
Types resolveImport in a TS project, hover a symbol
Tree-shaking worksImport one function, check bundle size
Both formats loadTest in an ESM and a CJS project
Peer deps aren't bundledgrep -r "react" dist/ should be minimal
Sourcemaps point to sourceClick 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.