Back to core workflows Fix dependency resolution Tune package metadata Jump to monorepo patterns

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:

Exact symptoms and error messages Rollup finishes but warns about modules it could not resolve into the bundle: Exact symptoms and error messages Rollup finishes but warns about modules it could not resolve into the bundle:
Exact symptoms and error messages — the core idea of this section at a glance.
(!) 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.

Root cause analysis Rollup bundles everything reachable from the entry unless told otherwise. Root cause analysis Rollup bundles everything reachable from the entry unless told otherwise.
Root cause analysis — the core idea of this section at a glance.

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.

Rollup bundles everything reachable from the entry unless told otherwise, so 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 a framework this is exactly what you want — but an unintended external means a real dependency will be missing at the consumer's runtime. The warning is Rollup telling you it made a guess about external-ness, and your job is to replace the guess with an explicit rule.

Subpath imports complicate the picture. A peer like a framework is easy to match by exact string, but its subpaths — a JSX runtime, a client entry, a scoped submodule — 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 problem for stateful packages. Matching subpaths with a prefix rule is what keeps a dependency fully external.

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:

Resolution and configuration patch Make external-ness explicit so the warning becomes a deliberate decision. Resolution and configuration patch Make external-ness explicit so the warning becomes a deliberate decision.
Resolution and configuration patch — the core idea of this section at a glance.
// 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.

Derive external from the manifest so it cannot drift, and match subpaths with a prefix:

// rollup.config.mjs
import { readFileSync } from 'node:fs';
const pkg = JSON.parse(readFileSync('./package.json', 'utf8'));
const deps = [
  ...Object.keys(pkg.peerDependencies ?? {}),
  ...Object.keys(pkg.dependencies ?? {}),
];
export default {
  input: 'src/index.ts',
  external: (id) =>
    /^node:/.test(id) || deps.some((d) => id === d || id.startsWith(d + '/')),
  output: [
    { file: 'dist/index.mjs', format: 'es' },
    { file: 'dist/index.cjs', format: 'cjs' },
  ],
};

The prefix check keeps subpath imports external alongside the package root, and the node: regex externalizes built-ins.

CLI validation and debug commands

CLI validation and debug commands CLI validation and debug commands in production JavaScript package workflows. CLI validation and debug commands CLI validation and debug commands in production JavaScript package workflows.
CLI validation and debug commands — the core idea of this section at a glance.
# 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 external from peerDependencies/dependencies so 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.
Prevention and CI guardrails Prevention and CI guardrails in production JavaScript package workflows. Prevention and CI guardrails Prevention and CI guardrails in production JavaScript package workflows.
Prevention and CI guardrails — the core idea of this section at a glance.
  • Generate external from peerDependencies/dependencies so the lists cannot drift.
  • Match subpath imports with a prefix rule, not an exact string.
  • Externalize node: built-ins with a regex rather than listing them one by one.
  • Fail CI if a package appears external that is not declared as a peer or dependency.

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.

Manifest-driven external peerDependencies generates the external list, verified in CI. peerDependencies source of truth generate external one derived list CI assertion no undeclared external
Derive external from the manifest so it can never drift from the declared peers.

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.

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 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.
When bundling a dependency is the right call — the core idea of this section at a glance.

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.

Keeping external in sync with the manifest

The most common cause of a broken published library is external and the manifest drifting apart: a package listed as external but not declared as a peer or dependency 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. Reading peerDependencies and dependencies from package.json and building the external function from them means adding a dependency automatically externalizes it, with no separate list to maintain.

Manifest-driven external Derive external from the manifest, verify in CI. peerDependencies source of truth derive external one list CI assertion no undeclared external
Deriving external from the manifest and checking the artifact keeps the boundary honest.

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 and hits a missing module — into a red build in your own pipeline, where it is cheap to fix. The combination of deriving external from the manifest and verifying the built artifact against it is what keeps the boundary honest as dependencies change: the external list tracks the manifest automatically, and the CI check catches any case where the built output externalizes something the manifest does not declare.

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.

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 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.
When bundling a dependency is the right call — the core idea of this section at a glance.

The rule of thumb is to externalize anything a consumer is likely to share — frameworks, common utilities — and only bundle small, private, or deliberately-frozen code. A framework must be external so the consumer's single copy is used; a two-line internal helper is fine to inline. Whatever you decide, make it explicit in the external rule so the choice is visible and reviewable rather than an accident of what Rollup happened to find on disk. An unresolved-dependency warning is Rollup asking you to make that decision deliberately, and answering it with an explicit rule — external for shared packages, bundled for small private ones — is what turns the warning from noise into a documented packaging decision.

Interaction with node-resolve and commonjs plugins

When a Rollup config also uses @rollup/plugin-node-resolve and @rollup/plugin-commonjs, they can interact with the external decision in ways that produce or hide unresolved-dependency warnings. The external function runs first to decide what not to bundle, but a mis-ordered or mis-configured node-resolve can pull a package back in that you meant to externalize, or fail to resolve a package's entry so it appears unresolved. Understanding the plugin order — external decision, then resolution, then CommonJS conversion — is what lets you diagnose which stage is producing the warning.

Plugin order external decides, then resolve, then commonjs. external first what not to bundle node-resolve preferBuiltins commonjs convert cjs deps
Keep the external function authoritative so a resolution plugin doesn't override it.
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()],
};

Keeping preferBuiltins: true ensures Node built-ins resolve to the runtime rather than being bundled or warned about, and keeping the external function authoritative means node-resolve does not override your intent. When a warning appears despite an external rule that should match, the usual cause is a plugin re-including the package, so the fix is to confirm the external function actually matches the specifier and that no plugin option is countermanding it. Verifying the built output with publint confirms no peer leaked in despite the plugin interactions.

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.

Is Rollup's 'treating module as external' warning a problem?

For peer dependencies, no — 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. Derive external from your manifest so the outcome is explicit rather than a guess.

Why is a subpath of my peer dependency getting bundled?

An exact-match external rule matches the package root but not its subpaths (like a JSX runtime), so they get bundled. Use a prefix rule (id === d || id.startsWith(d + '/')) so a peer and all its subpaths stay external together.

Should runtime dependencies be external too?

For a library, usually yes — externalizing runtime dependencies keeps them de-duplicated in the consumer's tree rather than inlined into your artifact. Only bundle a dependency when you deliberately want to vendor small, private, or frozen code.

Why is a package I marked external still bundled by Rollup?

A resolution plugin like @rollup/plugin-node-resolve may be re-including it, or your external rule does not actually match the specifier (often a subpath). Confirm the external function matches, keep it authoritative over the plugins, and set preferBuiltins: true so built-ins are never bundled.

Related

Bundling and Build Tooling for Libraries