Resolving Rollup 'Unresolved Dependencies' Warnings
Rollup prints '(!) Unresolved dependencies' or 'treating X as external' warnings during a library build. This page explains what the warning means, why it is usually correct for peers, and how to configure external so it is intentional rather than accidental.
Exact symptoms and error messages
Rollup finishes but warns about modules it could not resolve into the bundle:
(!) Unresolved dependencies
https://rollupjs.org/troubleshooting/#warning-treating-module-as-external-dependency
react (imported by src/index.ts)
react/jsx-runtime (imported by src/Button.tsx)
Root cause analysis
Rollup bundles everything reachable from the entry unless told otherwise. When it meets an import it was not asked to bundle and cannot find a matching external rule, it warns and leaves the import as a bare reference. For a peer dependency like React this is exactly what you want — but an unintended external means a real dependency will be missing at runtime. The distinction maps to the dependency buckets in Understanding package.json Fields.
Rollup treats external-ness as a per-import decision, and by default it only externalizes things it cannot find on disk. That means a package installed in your node_modules will be bundled unless you explicitly externalize it — the opposite of what a library usually wants. The warning is Rollup telling you it made a guess; your job is to replace the guess with a rule derived from your manifest so the outcome is deterministic across machines and CI.
Subpath imports complicate the picture. A peer like react is easy to match by exact string, but react/jsx-runtime, react-dom/client, and @scope/pkg/feature are separate module specifiers that an exact-match rule misses. The result is a partially-externalized dependency: the root is external but a subpath gets bundled, which duplicates code and can reintroduce the dual-instance hazard for stateful packages.
Resolution and configuration patch
Make external-ness explicit so the warning becomes a deliberate decision. Derive external from your peerDependencies so the two never drift apart:
// rollup.config.mjs
import { readFileSync } from 'node:fs';
const pkg = JSON.parse(readFileSync('./package.json','utf8'));
const external = [
...Object.keys(pkg.peerDependencies ?? {}),
...Object.keys(pkg.dependencies ?? {}),
/^node:/,
];
export default {
input: 'src/index.ts',
external: (id) => external.some((e) => e instanceof RegExp ? e.test(id) : id === e || id.startsWith(e + '/')),
output: [
{ file: 'dist/index.mjs', format: 'es' },
{ file: 'dist/index.cjs', format: 'cjs' },
],
};
When you also run @rollup/plugin-node-resolve, make sure it is not silently re-including packages you meant to externalize. The external function runs first, but a mis-ordered plugin config can pull a dependency back in:
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
export default {
input: 'src/index.ts',
external,
plugins: [
resolve({ preferBuiltins: true }),
commonjs(),
],
};
Keep preferBuiltins: true so Node built-ins resolve to the runtime rather than being bundled, and confirm with publint that no peer leaked into the output.
CLI validation and debug commands
# Confirm only intended packages are external
pnpm build 2>&1 | grep -A20 'Unresolved dependencies' || echo 'none'
# Verify the artifact keeps peers as bare imports
grep -E "from ['\"]react" dist/index.mjs
# Ensure nothing that should be bundled leaked out
pnpm exec publint
Prevention and CI guardrails
- Generate
externalfrompeerDependencies/dependenciesso the lists cannot drift. - Fail CI if a package appears external that is not declared as a peer or dependency.
- Match subpath imports (
react/jsx-runtime) with a prefix rule, not an exact string. - Externalize
node:built-ins with a regex rather than listing them one by one.
Keeping external in sync with peerDependencies
The most common cause of a broken published library is external and peerDependencies drifting apart: a package listed as external but not declared as a peer is simply missing at the consumer's runtime, and a peer that is not external gets duplicated into your bundle. The durable fix is to make the manifest the single source of truth and derive the external list from it, so the two can never disagree.
Add a CI assertion that fails when the built artifact imports a package that is neither a declared peer nor a runtime dependency. That turns a silent packaging bug — the kind that only surfaces when a consumer installs your library — into a red build in your own pipeline, which is exactly where you want to catch it.
When bundling a dependency is the right call
Externalizing is the default for a library, but there are cases where vendoring a dependency into your bundle is correct. A tiny, stable utility with no independent value to the consumer, a package you have patched and want to freeze, or an internal helper that should not appear in the consumer's dependency tree are all reasonable to bundle.
The trade-off is duplication versus control. Bundling removes a runtime dependency and its version-resolution risk, but if the consumer also installs that package directly they now carry two copies. The rule of thumb: externalize anything a consumer is likely to share (frameworks, common utilities), and only bundle small, private, or deliberately-frozen code. Whatever you decide, make it explicit in the external rule so the choice is visible and reviewable rather than an accident of what Rollup found on disk.
Frequently Asked Questions
Is 'treating module as external' always a problem?
No — for peer dependencies it is the desired behavior. It is only a bug when a package you meant to bundle is left external and therefore missing from the consumer's install.
Should dependencies be external too?
For a library, usually yes: externalizing runtime dependencies keeps them de-duplicated in the consumer tree rather than inlined into your artifact. Only bundle a dependency when you deliberately want to vendor it.
Should I externalize subpath imports like react/jsx-runtime?
Yes, with a prefix rule rather than an exact string. Match react and anything starting with react/ so the JSX runtime and other subpaths stay external alongside the package root, avoiding a partially-bundled dependency.
Why is node-resolve bundling a package I marked external?
The external decision and @rollup/plugin-node-resolve can interact: if resolve runs with settings that re-include the package, it overrides your intent. Keep the external rule authoritative and set preferBuiltins: true so built-ins are never bundled.
Related
- Understanding package.json Fields — the dependency buckets that decide what should be external.
- Bundling and Build Tooling for Libraries — the overview for library build configuration.