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.
The problem exists because build.lib inherits Rollup’s default behavior for application builds: resolve every bare specifier to a file in node_modules and pull that file’s module graph into the output. That default is correct for an app — you want one self-contained bundle — but wrong for a library, where the framework is supplied by the consumer, not by you. Externalization is the switch that tells Rollup to stop at the import boundary and emit import { useState } from "react" verbatim instead of walking into node_modules/react/index.js and inlining its source. The specifier is left for the consumer’s bundler (or their <script> tag, for UMD) to resolve against the single React instance already in their tree.
Getting this wrong is not a size regression you can defer. React, and most frameworks with hooks or reactive context, rely on module-level singleton state: the dispatcher React reads inside useState lives in a module variable that only has meaning if every component shares the exact same copy of react. Ship a second, inlined copy inside your library and the host renders components against one dispatcher while your library reads another, producing the classic Invalid hook call or Cannot read properties of null (reading 'useContext') at runtime — not at build time, and often only in a consumer’s app, never in your own test harness. Externalization sits at the very end of the build pipeline, in Rollup’s resolution and output phase, which is exactly why the symptom surfaces downstream and why every check in this guide inspects the emitted bundle rather than trusting the config.
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.
The distinction is not cosmetic. dependencies is a promise that npm will install the package into the consumer’s tree as a transitive requirement of yours; peerDependencies is a promise that the consumer already provides it and you will reuse their copy. Only the second promise is compatible with a bare import "react" in your published output — if you externalize something you listed under dependencies, npm never installs it for the consumer and the bare import fails to resolve. So the peerDependencies block is doing double duty: it is the contract shown to the consumer and, in the config below, the literal source of the external list. Keeping both derived from one field is what stops the two from drifting apart when you later add a peer like react-dom or @emotion/react. If your library genuinely needs the framework during local development and tests, mirror each peer under devDependencies as well; npm installs devDependencies for you but never for consumers, so it satisfies your test runner without contradicting the externalization.
dependencies/peerDependencies distinction decides whether external can even take effect.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’s own source landed in your output — the definition ofuseStateonly appears innode_modules/react, so its presence is proof the module was walked and inlined rather than left external. This is the ground-truth check because a consumer’sInvalid hook callis several steps removed from the cause; the bundle either contains framework source or it does not, and no amount of app-side debugging changes that fact. Run it against every emitted format (dist/), not just the ESM file, since a matcher can be correct for one format and silently wrong for another. - 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. Rollup’s pipeline runsresolveIdthenloadfor every import; an externalized specifier is resolved to an{ external: true }result and never reachesload, so seeing a concrete file path in the debug stream means yourexternalpredicate returned false for that id. This is the fastest way to tell a config mistake (predicate never matched) from a packaging mistake (predicate matched but the peer is also underdependencies), because the debug output shows the exact specifier string Rollup tested — including sub-paths you may not have anticipated. - 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. It is emitted at the output-generation stage, once per format that needs a global, and it is easy to miss in a noisy build log — grep the build output forMissing globalin CI so it fails loudly instead of shipping a UMD bundle that throwsReact is not definedthe first time someone loads it from a CDN. Theesandcjsoutputs never produce this warning because their module systems resolve bare specifiers themselves, so its absence does not prove the whole build is correct. - 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 root cause is that Rollup compares an arrayexternalentry against the full specifier string by exact equality:"react/jsx-runtime" === "react"is false, so the sub-path is treated as first-party and bundled. The fix is the function/regex matcher below, which tests a prefix instead of an exact string; confirm the fix by re-grepping for a barefrom "react/jsx-runtime"import in the output rather than the runtime’s inlinedjsx/jsxsfactory functions.
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.
How the matcher works under the hood
Rollup calls the external function once for every import specifier it encounters, with three arguments: the raw specifier (id), the id of the importing module, and whether the id has already been resolved. We use only the first. Returning true marks the id external and stops resolution; returning false (or a falsy value) lets normal resolution proceed and the module gets bundled. Because the function sees the specifier before Rollup resolves it to a file path, it operates on the string the author wrote — "react/jsx-runtime", not /abs/path/node_modules/react/jsx-runtime/index.js — which is exactly why a prefix test is reliable and a path test is not.
The regex itself has three deliberate parts. The ^ anchor forces the match to start at the beginning of the specifier so some-react-helper cannot match react. The alternation is built from the escaped peer names, so a scoped name like @scope/ui contributes @scope/ui with its metacharacters neutralized. The trailing ($|\/) is the load-bearing piece: it matches either the end of the string (the bare react import) or a following slash (any sub-path like react/jsx-runtime), while still refusing react-dom as a match for react because the character after react there is -, not / or end-of-string. That single group is what lets one entry cover a package and all of its deep imports without over-matching a sibling whose name merely starts with the same letters.
Enumerating output.globals by hand is unavoidable and worth understanding. In a UMD or IIFE bundle there is no module loader, so an externalized import ... from "react" compiles to a reference to a property on the global object. Rollup cannot invent the name — react might be exposed as React, window.React, or something else entirely depending on how the framework ships its browser build — so you declare the mapping. For react/jsx-runtime there is no canonical global at all; UMD consumers rarely use the automatic runtime, so mapping it to any stable identifier (here jsxRuntime) is sufficient to silence the warning, and you should drop umd from formats entirely if you do not actually ship a browser-global build.
globals must still be listed by hand.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.
The four checks are deliberately independent, testing different formats and different failure modes, because a single passing check does not generalize. Check 1 proves no framework source was inlined into any file — it catches the total-failure case where external never fired. Checks 2 and 3 prove the two module systems that matter most emit the correct shape: a bare ESM import and a CJS require respectively, since a matcher can be right for ESM output and still misconfigured such that the CJS output inlines. Check 4 (publint) validates the published surface against the packaging rules npm and bundlers actually enforce, including the exports map and the bundled-peer heuristic, catching mistakes that a raw grep would miss. Run all four in CI; the grep-based ones exit non-zero on the wrong result and are trivial to wire into a test:pack script.
The tarball round-trip is the only check that reproduces the consumer’s real resolution, so treat it as the acceptance gate. A dependency-tree collision — two Reacts under different paths in node_modules — is exactly what breaks hooks, and npm ls react in the host app is the direct measurement of it. If it prints a deduplicated single version, the shared-singleton contract holds; if it prints two, one of them is coming from inside your library and an earlier check should have caught it.
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']. The symptom is subtle because the bundle still works in isolation — the inlined jsx factory is functionally correct — so the only visible sign is a bundle a few kilobytes larger than expected and a duplicate-runtime warning in a strict consumer. The react-dom/client sub-path (createRoot) has the same shape, as does react/jsx-dev-runtime if any dev build leaks through; a prefix matcher covers all of them at once, which is the whole reason to prefer it over an ever-growing array you have to maintain by hand.
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. The trap appears when a utility like clsx or date-fns is small enough that you would rather bundle it, but it sits under dependencies and someone widens the matcher to [...peers, ...deps] to shrink duplication. That inverts the contract: bundled dependencies are the correct default for a library because they are your implementation details, and only the framework-level singletons need to be shared. The confirmation is the same tarball round-trip — install into a bare host app and import your entry; a Cannot find module 'clsx' at that point means you externalized something the consumer was never told to install.
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. The reason it slips through local testing is that most CI and unit tests import the ESM or CJS entry, where bare specifiers resolve through the module system and globals is never consulted — the UMD path only executes when a browser loads the file via <script> with the framework already on window. If you do keep a UMD build, also make the peer’s own global a load order requirement in your docs: the consumer must include React’s UMD build before yours, or window.React is undefined at evaluation time regardless of a correct globals map.
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). The escaping matters specifically for scoped and dotted names: @scope/pkg contains / (harmless but should not be treated specially) and packages like @babel/runtime or names containing . would otherwise let the . match any character. Without the escape, lodash.merge in the peer list would build a regex where . matches any byte, so lodashXmerge — or worse, a path fragment you never intended — could match. The unescaped ^ and $ you add yourself are anchors; the escaped ones inside a package name are literals. Confirm the boundary behavior with a one-off assertion: externalRegex.test('react') and externalRegex.test('react/jsx-runtime') should both be true while externalRegex.test('react-dom-helper') stays false.
When not to externalize
Externalization is correct for framework runtimes and other singletons the consumer already owns; it is wrong as a blanket policy. If you are building a leaf application rather than a library, build.lib is the wrong tool and you want everything bundled. Even within a library, a small pure-function utility with no shared state is usually better bundled than externalized: forcing consumers to install a transitive helper to satisfy a peer trades a few hundred bytes of duplication for a worse install experience and a real risk of version-skew bugs. Reserve peers for packages where a second copy is a correctness problem, not merely a size one — anything with module-level singleton state (React, react-dom, Vue’s reactivity, a state-management store, a CSS-in-JS runtime that keys a cache by identity) qualifies; a stateless formatting library does not. When you are unsure, ask whether two copies loaded side by side would misbehave; if the answer is no, bundle it.
Comparison with Vue and other frameworks
The mechanism is framework-agnostic but the specifier set differs. For Vue you externalize vue and map output.globals to Vue; the equivalent of React’s react/jsx-runtime trap is @vue/runtime-core and @vue/runtime-dom leaking in through vue’s own dependency graph, which the prefix matcher over peerDependencies handles as long as you list the peers Vue actually exposes to consumers. Svelte libraries externalize svelte and its sub-paths (svelte/internal, svelte/store), and here the sub-path problem is more acute because Svelte’s compiler emits imports from several svelte/* entry points — an exact-string external would miss most of them, making the prefix matcher not just convenient but mandatory. Solid, Preact, and Angular follow the same pattern: identify the packages whose singleton identity the consumer must own, list exactly those under peerDependencies, and let the derived regex externalize the package plus every sub-path in one rule. The config in this guide does not change; only the contents of the peerDependencies block do.
CI integration
Externalization regressions are easy to reintroduce — a dependency moved from peerDependencies to dependencies in a careless refactor, or a new sub-path import added by a framework upgrade — so the verification belongs in CI, not in a one-time manual check. Wire the same greps and publint into a script that runs after the build, and fail the job on any inlined framework source. The following package.json script encodes the acceptance gate:
// package.json — run in CI after `vite build`
{
"scripts": {
"build": "vite build",
// Fails the build if any framework source is inlined, if the ESM
// output is missing the bare react import, or if publint objects.
"verify:externals": "grep -rq 'function useState' dist/ && exit 1; grep -Eq 'from \"react(/jsx-runtime)?\"' dist/index.mjs || exit 1; npx publint",
"prepublishOnly": "npm run build && npm run verify:externals"
}
}
Hanging the check off prepublishOnly means npm publish refuses to run against a bundle that inlined a peer, which is the one place the mistake is most expensive to catch after the fact — once a bad version is on the registry, every consumer who installs it inherits the duplicate runtime until you publish a patch.
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.