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

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.

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.

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.

Related

Dependency Resolution Explained