Externalizing Peer Dependencies in Vite Library Mode

When you publish a component library with build.lib, a single missing external entry causes Vite to inline React, Vue, or your design-system runtime directly into the bundle, so every consumer ships two copies and breaks framework identity checks. This guide pins down a peerDependencies-driven rollupOptions.external, the output.globals it forces for UMD, and how to verify the result in the emitted bundle. It assumes you have the broader Vite library mode and package bundling setup already in place and only need to get externalization correct.

Externalized versus bundled peer dependency in a library build Without external, React source is inlined into the bundle; with external, the import is left bare for the consumer to resolve a single shared copy. import { useState } from 'react' No external (wrong) dist/index.mjs + inlined React source ~45 KB, 2 copies at runtime Invalid hook call external: peers (right) dist/index.mjs import 'react' (bare) consumer resolves 1 copy single shared instance hooks work across boundary
Figure: an externalized peer leaves a bare import; a bundled peer inlines source and duplicates the runtime.

Prerequisites & Reproducible Setup

Start from a working build.lib config. Install the toolchain and confirm React lives only under peerDependencies, never dependencies:

# Vite 6.x / Rollup 4.x / Node 18.18+
npm install -D vite@^6 typescript
npm pkg set peerDependencies.react=">=18" peerDependencies.react-dom=">=18"
npm pkg delete dependencies.react dependencies.react-dom
npm ls react   # should print "(empty)" or only a dev-only tree

If react still appears under dependencies, Rollup will treat it as a first-party module and bundle it regardless of external. The peerDependencies block is also what we will read at build time to derive the external list automatically.

Diagnosis Workflow

  1. Confirm the symptom in the output, not the app. Grep the emitted ESM bundle for framework internals: grep -c "function useState" dist/index.mjs. Any non-zero count means React was inlined.
  2. Inspect Rollup’s resolution. Run npx vite build --debug and watch for react being loaded (a file path under node_modules/react) rather than left as an external specifier. Loaded means bundled.
  3. Check the warning channel. When external is correct but output.globals is missing for a umd/iife build, Rollup prints (!) Missing global variable name ... react (guessing 'React'). That warning is the precise signal you still owe a globals entry.
  4. Distinguish exact vs sub-path misses. An external: ['react'] string misses react/jsx-runtime, which the automatic JSX transform injects. Confirm with grep -o "react/jsx-runtime" dist/index.mjs — if it is inlined rather than imported, your matcher is too narrow.

The Complete Solution Config

This vite.config.ts derives the external matcher from peerDependencies, so adding a peer to package.json automatically externalizes it. A regex catches both the bare package and any sub-path (react, react/jsx-runtime, @scope/pkg/foo).

// vite.config.ts — Vite 6.x / Rollup 4.x
import { defineConfig } from 'vite';
import { resolve } from 'node:path';
import { readFileSync } from 'node:fs';

// Read declared peers at build time so the list never drifts.
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'));
const peerNames = Object.keys(pkg.peerDependencies ?? {});

// Build one regex that matches each peer and any of its sub-paths.
// e.g. /^(react|react-dom)($|\/)/
const externalRegex = new RegExp(
  `^(${peerNames.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})($|\\/)`
);

export default defineConfig({
  build: {
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
      name: 'MyLib',
      formats: ['es', 'cjs', 'umd'],
      fileName: (format) =>
        format === 'es' ? 'index.mjs' : format === 'cjs' ? 'index.cjs' : `index.${format}.js`,
    },
    rollupOptions: {
      // A function external matches sub-paths the array form would miss.
      external: (id) => externalRegex.test(id),
      output: {
        // UMD/IIFE need a runtime global for every externalized specifier.
        globals: {
          react: 'React',
          'react-dom': 'ReactDOM',
          'react/jsx-runtime': 'jsxRuntime',
        },
      },
    },
  },
});

The function form of external is the key choice: passing an array of exact strings forces you to enumerate every sub-path import, whereas the regex matcher externalizes react, react-dom, and react/jsx-runtime from a single source of truth. output.globals still has to be enumerated because UMD needs a concrete variable name per specifier — there is no regex equivalent for that mapping.

Verification

Rebuild and prove the peers are gone from the bundle:

npx vite build

# 1. No framework internals inlined anywhere.
grep -rc "function useState" dist/ || echo "clean: React not inlined"

# 2. Peers appear as bare imports in the ESM output.
grep -E "from \"react(/jsx-runtime)?\"" dist/index.mjs
# import { useState } from "react";
# import { jsx } from "react/jsx-runtime";

# 3. CJS output uses require(), not inlined source.
grep -E "require\(\"react" dist/index.cjs

# 4. Mechanical lint of the published surface.
npx publint

A correct build keeps dist/index.mjs small (kilobytes, not tens of kilobytes) and shows only bare import "react" / require("react") statements. publint should report no peer dependency ... is bundled issues. As a final check, npm pack the tarball and install it into a host app that already has React; npm ls react in that app must show exactly one React version.

Gotchas & Edge Cases

react/jsx-runtime slips through an exact-string external

The automatic JSX runtime (@vitejs/plugin-react’s default) imports from react/jsx-runtime, a sub-path that external: ['react'] does not match. The regex/function matcher above handles it; if you must keep the array form, list every sub-path explicitly: ['react', 'react-dom', 'react/jsx-runtime'].

Externalizing a transitive dependency you actually own

Only peerDependencies belong in external. If you externalize a regular dependencies package, consumers get Cannot find module because they never installed it. Keep the matcher sourced from peerDependencies exactly, and leave genuine first-party dependencies bundled.

Missing output.globals breaks only the UMD build

es and cjs formats ignore globals entirely, so a missing entry passes silently until the UMD/IIFE bundle throws React is not defined on a CDN. Either supply a globals entry for every external, or drop umd/iife from formats if you do not ship a CDN build.

Scoped packages need an anchored regex

A naive new RegExp(name) matches @scope/pkg anywhere in a path and can over-externalize. Anchor with ^ and terminate with ($|\/) as shown, and escape regex metacharacters in package names (the .replace in the config does this).