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.

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.

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.

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.

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

Bundling and Build Tooling for Libraries