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.
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
- 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. - Inspect Rollup’s resolution. Run
npx vite build --debugand watch forreactbeing loaded (a file path undernode_modules/react) rather than left as an external specifier. Loaded means bundled. - Check the warning channel. When
externalis correct butoutput.globalsis missing for aumd/iifebuild, Rollup prints(!) Missing global variable name ... react (guessing 'React'). That warning is the precise signal you still owe aglobalsentry. - Distinguish exact vs sub-path misses. An
external: ['react']string missesreact/jsx-runtime, which the automatic JSX transform injects. Confirm withgrep -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).
Related
- Vite library mode and package bundling — the full
build.libsetup, multi-format output, types, and exports map this externalization plugs into. - Understanding ESM vs CommonJS in modern bundlers — why bare
import/requirespecifiers resolve to the consumer’s single copy. - Vite version compatibility reference — Vite/Rollup versions where the function-form
externaland JSX runtime behave as described.