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.

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.

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.

React must be a peer, not a dependency A framework listed under dependencies is treated as first-party and bundled regardless of external; moving it to peerDependencies makes it externalizable and lets the config derive the external list from that block. dependencies.react treated as first-party bundled even with external peerDependencies.react externalizable source of truth for external list
Figure: the dependencies/peerDependencies distinction decides whether external can even take effect.

Diagnosis Workflow

Four-step externalization diagnosis Confirm the symptom in the emitted bundle not the app, inspect Rollup resolution to see if react was loaded, check the missing-global warning channel for UMD, then distinguish an exact-string miss from a sub-path miss like react/jsx-runtime. 1 · grep bundlefunction useState? 2 · --debug resolveloaded = bundled 3 · warning channelmissing global? 4 · exact vs sub-pathjsx-runtime?
Figure: the bundle is the ground truth — the app symptom (duplicate React) is downstream of what these steps inspect.
  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’s own source landed in your output — the definition of useState only appears in node_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’s Invalid hook call is 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.
  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. Rollup’s pipeline runs resolveId then load for every import; an externalized specifier is resolved to an { external: true } result and never reaches load, so seeing a concrete file path in the debug stream means your external predicate 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 under dependencies), because the debug output shows the exact specifier string Rollup tested — including sub-paths you may not have anticipated.
  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. 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 for Missing global in CI so it fails loudly instead of shipping a UMD bundle that throws React is not defined the first time someone loads it from a CDN. The es and cjs outputs never produce this warning because their module systems resolve bare specifiers themselves, so its absence does not prove the whole build is correct.
  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 root cause is that Rollup compares an array external entry 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 bare from "react/jsx-runtime" import in the output rather than the runtime’s inlined jsx/jsxs factory 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.

Deriving the external matcher from peerDependencies The config reads package.json peerDependencies, builds an anchored regex matching each peer and its sub-paths, and passes it as a function external; output.globals is still enumerated separately because UMD needs a concrete global name per specifier. peerDependenciessingle source of truth anchored regex^(react|react-dom)($|/) external: (id) => testcovers sub-paths output.globalsenumerated for UMD
Figure: one regex externalizes every peer and sub-path; only globals must still be listed by hand.

Verification

Four externalization checks Confirm no framework internals are inlined, that peers appear as bare imports in the ESM output, that the CJS output uses require rather than inlined source, and that publint reports no bundled-peer issue. grep "function useState" dist/ no match grep import "react" dist/index.mjs bare import grep require("react") dist/index.cjs require, not source npx publint no bundled-peer issue
Figure: ESM bare import, CJS require, no inlined internals, clean publint — four independent proofs.

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

Four externalization edge cases react/jsx-runtime slips through an exact-string external; externalizing a real dependency causes cannot-find-module for consumers; a missing global breaks only the UMD build; and a scoped-package regex must be anchored to avoid over-externalizing. jsx-runtime slips through external: ['react'] misses the sub-path — the regex/function matcher catches it. externalizing a real dep consumers get Cannot find module — source the matcher from peerDependencies only. missing global → UMD only es/cjs ignore globals; UMD throws React is not defined on a CDN. anchor scoped regexes ^ and ($|/) plus escaped metachars stop @scope/pkg over-matching.
Figure: two matcher-scope traps and two format-specific traps.

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.