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

When to Use peerDependencies vs devDependencies

Misclassifying a single dependency is one of the most common reasons a library works in its own repository but breaks the moment a consumer installs it — or, conversely, refuses to install at all because of a spurious version conflict. The choice between peerDependencies and devDependencies is not stylistic; each field changes what the resolver fetches, what ships to consumers, and whether a version mismatch is a warning or a hard failure.

Exact symptoms

Exact symptoms Beyond these strings you will also see duplicate nodemodules trees, consumer bundles that ship two copies of a framework Exact symptoms Beyond these strings you will also see duplicate nodemodules trees, consumer bundles that ship two copies of a framework, and silent runtime API mismatches wher
Exact symptoms — the core idea of this section at a glance.
npm WARN unmet peer react@^18.0.0
npm ERR! peer dependency not satisfied
Module not found: Can't resolve 'react' in '/app/node_modules/@scope/ui/dist'
npm error ERESOLVE unable to resolve dependency tree

Beyond these strings you will also see duplicate node_modules trees, consumer bundles that ship two copies of a framework, and silent runtime API mismatches where your library calls a method the consumer's installed version does not have.

Root cause analysis

Package managers strictly separate build-time tooling from runtime host requirements. devDependencies are ephemeral: they install for development but are explicitly stripped from production installs (npm install --omit=dev) and are never fetched for consumers of your published package. peerDependencies express a contract — "the host application must provide this package, and at this version range" — without bundling a copy themselves.

The two classic misclassifications are mirror images:

  • Placing a runtime host library (React, Vue, the Webpack runtime) in devDependencies means it vanishes from a consumer's install, producing Module not found because your code expects it but nothing supplies it.
  • Declaring build-only tooling (linters, bundlers, test runners) as peerDependencies forces every consumer to satisfy a version range they have no reason to care about, triggering strict resolution conflicts and ERESOLVE halts.

Both failures are really resolution failures, so the mechanics in Dependency Resolution Explained explain why they happen. When the peer contract cannot be satisfied at all you land in the hard-failure path documented in Fixing npm ERESOLVE Peer Dependency Conflicts; when the host is installed twice from incompatible ranges you get the symptom covered in Deduplicating Duplicate React Versions.

Dependency classification decision A decision flow: if the consumer must supply the package it is a peer dependency, otherwise if it is only used to build or test it is a dev dependency. Is the package used at the consumer's runtime? Consumer must supply it React, Vue, the host runtime peerDependencies Only to build or test tsup, eslint, vitest devDependencies Optional? add peerDependenciesMeta yes no
Classify by who needs the package at runtime: the consumer's runtime means a peer dependency; build or test only means a dev dependency.

Field comparison at a glance

Field Installed for consumers? In production install? Resolver behavior on mismatch
dependencies Yes Yes Resolved and installed automatically.
peerDependencies No (consumer must supply) N/A — consumer's tree Warns or fails (ERESOLVE) if the consumer's version is out of range.
devDependencies No No (stripped via --omit=dev) Only relevant in your own repo.
optionalDependencies Yes, best-effort Yes Install continues if it cannot be resolved.

The single most important column is the first: anything a consumer's running code reaches into must be supplied by the consumer, which is exactly what peerDependencies declares.

Resolution and CLI debugging

  1. Audit usage. Separate runtime host requirements from build/test tooling. Host frameworks belong in peerDependencies; linters, bundlers, and test runners belong in devDependencies.
  2. Refactor package.json. Move host-environment packages to peerDependencies with a deliberate semver range, and remove duplicate entries from devDependencies to avoid version collisions and bundle bloat.
  3. Add peerDependenciesMeta. Mark non-critical peers as optional so consumers who do not use that integration are not blocked.
  4. Handle monorepos. Use the workspace:* protocol or a package-manager override to allow flexible local resolution without tripping strict peer checks during development.
  5. Validate in isolation. Install your library into a fresh consumer project and confirm zero unmet peer warnings.
Resolution and CLI debugging Note that the host framework appears in both peerDependencies (the consumer contract) and devDependencies (so your own t Resolution and CLI debugging Note that the host framework appears in both peerDependencies (the consumer contract) and devDependencies (so your own tests and Storybook have something to res
Resolution and CLI debugging — the core idea of this section at a glance.

Diagnostic CLI commands

# Diagnose unmet peers and tree conflicts
npm ls --all
npm explain react   # replace with the package you are investigating

# Temporary bypass for local development only (use sparingly)
npm install --legacy-peer-deps
pnpm install --strict-peer-dependencies=false

# Validate a clean install in an isolated consumer
mkdir test-consumer && cd test-consumer
npm init -y && npm install ../your-library
npm ls --depth=0

Required configuration patch

{
  "peerDependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  },
  "peerDependenciesMeta": {
    "react-dom": {
      "optional": true
    }
  },
  "devDependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0",
    "typescript": "^5.7.0",
    "vitest": "^3.0.0"
  }
}

Note that the host framework appears in both peerDependencies (the consumer contract) and devDependencies (so your own tests and Storybook have something to resolve against during development). This is intentional and does not bundle a copy into a consumer's install.

Worked monorepo example

Inside a workspace, the library and an example app both want the same React, but the library must still publish a peer contract for external consumers. Declare the peer in the library and satisfy it from the app with the workspace: protocol:

{
  "name": "@scope/ui",
  "peerDependencies": { "react": "^18.0.0" },
  "devDependencies": { "react": "^18.2.0" }
}
{
  "name": "@scope/example-app",
  "dependencies": {
    "@scope/ui": "workspace:*",
    "react": "^18.2.0"
  }
}

The app provides the single React copy that the library's peer range accepts, so the workspace hoists one instance and the published package still tells external consumers to bring their own React. If you instead listed react as a regular dependency of @scope/ui, every consumer would receive a second, bundled React and hit the duplicate-copy bug.

Prevention and CI guardrails

  • Run a manifest linter such as nplint/package-json-lint in CI to flag host frameworks accidentally left in devDependencies.
  • Add a publish-time check that installs the tarball (npm pack then install into a temp project) and asserts no unmet peer warnings.
  • Pin peer ranges to the widest version you actually test against, and bump the major when you drop support — a breaking peer requirement is a breaking release.
  • In monorepos, configure pnpm.peerDependencyRules so internal consumers do not emit noise for peers you knowingly satisfy.
  • Document required host versions in your README so consumers see the contract before they install.
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.

The contract each dependency type expresses

The three dependency types express different contracts about who provides a package and when. A peerDependency says 'the consumer must provide this, and my code will use their copy' — the contract for a plugin or adapter that integrates with a host framework, where using a second copy would break the integration. A devDependency says 'I need this to build or test, but it never reaches the consumer' — the contract for compilers, bundlers, and test runners. A regular dependency says 'I need this at runtime and it ships with me' — the contract for a library your code calls that the consumer should not have to know about.

Dependency contracts What each dependency type promises. Type Provided by Contract dependency you ships at runtime devDependency you build/test only peerDependency consumer use their copy
The contract is about who provides the package and whether it must be shared.

The confusion between peer and dev arises for tools that are used at build time but must match the consumer's version. A TypeScript ESLint plugin, for instance, needs ESLint present — but the consumer's ESLint, not its own — so it declares ESLint as a peer. Meanwhile it uses TypeScript to build itself, so TypeScript is a devDependency. The distinction is whether the package must use the consumer's copy (peer) or merely needs the tool to do its own work (dev). Asking 'must this match what the consumer has, or is it just for my own build?' resolves most peer-versus-dev decisions, because the peer contract is specifically about sharing the consumer's single instance.

optionalPeers and avoiding install warnings

A peer dependency that a consumer may or may not need is declared optional, which is how a package supports several host integrations without forcing all of them. Marking a peer optional in peerDependenciesMeta tells the package manager not to warn when the consumer omits it, so a package that adapts to both React and Vue can declare both as optional peers and a consumer using only React is not nagged about the missing Vue.

Optional peers Declare optional peers so unused integrations don't warn. peer + optional react, vue guard the import runs if present no spurious warning clean install
Optional peers let a package support several hosts without nagging every consumer.
{
  "peerDependencies": { "react": ">=18", "vue": ">=3" },
  "peerDependenciesMeta": {
    "react": { "optional": true },
    "vue": { "optional": true }
  }
}

The adapter code guards the optional import so it only runs when the peer is present, and the manifest documents the contract: these are integrations you may use, provided by you, not bundled by me. This is how a package offers optional capabilities without inflating every consumer's install or emitting spurious peer warnings. Getting the peer and optional-peer declarations right is what makes a plugin ecosystem work — each plugin uses the host's single copy, declares which hosts it supports, and marks the ones a given consumer might not use as optional, so the install is clean and the single-instance guarantee holds for whichever framework the consumer actually provides.

Declaring a package as both a peer and a dev dependency

A common and correct pattern for a plugin is to declare the same package as both a peer and a dev dependency, which confuses people until the two roles are clear. The peer declaration expresses the runtime contract — 'the consumer provides this' — while the dev declaration provides a copy for the plugin's own build and tests, since the consumer's copy is not present in the plugin's own repository. A TypeScript ESLint plugin declares ESLint as a peer (the consumer's ESLint is what it integrates with) and as a devDependency (so the plugin's own test suite has an ESLint to run against).

Peer and dev The same package can be a peer and a dev dependency. peer consumer provides it dev copy for own tests both roles runtime + build
A plugin declares its host as a peer (runtime) and a dev dependency (its own tests).
{
  "peerDependencies": { "eslint": ">=8" },
  "devDependencies": { "eslint": "^9.0.0" }
}

The two declarations serve different phases: the peer governs how the plugin resolves ESLint when a consumer installs it, and the dev governs how the plugin gets an ESLint to develop and test against. Neither is redundant — removing the peer would let the plugin bundle its own ESLint and break the integration, and removing the dev would leave the plugin unable to build itself. Recognizing that a package can legitimately be both, because it plays a runtime-integration role and a build-time role at once, resolves the most common source of confusion about which section a build-time-but-consumer-matched tool belongs in.

Frequently Asked Questions

Should I duplicate peerDependencies in devDependencies for local testing? Yes — listing the host framework in devDependencies gives your own tests, examples, and type checks something to resolve against, while peerDependencies declares the consumer contract. The two roles are independent, so duplication here is correct and does not ship an extra copy to consumers.

What happens if a consumer ignores a peer dependency warning? The package installs, but it may crash at runtime with Module not found, or exhibit silent API mismatches if the consumer's installed version lacks a feature your library calls. Optional peers declared via peerDependenciesMeta are the only ones safe to leave unmet.

How do I handle peer dependencies in a monorepo workspace? Link local packages with the workspace:* protocol and relax strict checks through pnpm.peerDependencyRules or an overrides block so internal resolution stays flexible during development without weakening the published contract.

When should I use peerDependenciesMeta? Use it to mark a peer as optional when your library supports multiple host environments or degrades gracefully without that integration, so consumers who do not need it are not blocked by an unmet-peer install failure.

When should a package be a peer dependency instead of a regular dependency?

When your code must use the consumer's copy rather than its own — a plugin or adapter integrating with a host framework, where a second copy would break the integration. A regular dependency ships with you; a peer is provided by the consumer and shared.

How do I stop peer-dependency install warnings for optional integrations?

Mark the peer optional in peerDependenciesMeta ({ "optional": true }), so the package manager does not warn when the consumer omits it. Guard the optional import in code so it only runs when the peer is present.

Can a package be both a peer and a dev dependency?

Yes, and for a plugin it usually should be. The peer declaration expresses the runtime contract that the consumer provides the package; the dev declaration gives the plugin its own copy to build and test against. A TypeScript ESLint plugin declares ESLint as both — a peer for integration, a devDependency for its own tests.

What range should a peer dependency use?

As wide as your package genuinely supports — >=18 rather than an exact pin — so the consumer's existing copy satisfies it and no second copy is installed. An over-narrow peer range forces duplication or install failures for consumers on a nearby version.

Do package managers auto-install peer dependencies?

It varies — npm 7+ auto-installs peers by default, while pnpm and Yarn typically warn and leave it to the consumer unless configured otherwise. Because the behavior differs, declare peers with accurate ranges and document them rather than relying on auto-install.

Related

Dependency Resolution Explained