Fixing npm ERESOLVE Peer Dependency Conflicts
Exact Symptoms
npm install halts and prints an ERESOLVE block. The verbatim error text looks like this:
npm error code ERESOLVE
npm error ERESOLVE unable to resolve dependency tree
npm error
npm error While resolving: my-app@1.0.0
npm error Found: react@17.0.2
npm error node_modules/react
npm error react@"17.0.2" from the root project
npm error
npm error Could not resolve dependency:
npm error peer react@"^18.0.0" from @acme/ui-kit@3.2.0
npm error node_modules/@acme/ui-kit
npm error @acme/ui-kit@"^3.2.0" from the root project
npm error
npm error Conflicting peer dependency: react@18.3.1
npm error node_modules/react
npm error peer react@"^18.0.0" from @acme/ui-kit@3.2.0
The two diagnostic lines that always appear are Could not resolve dependency: and a peer <pkg>@"<range>" from <pkg>@<version> block, frequently followed by Conflicting peer dependency:.
Root Cause Analysis
ERESOLVE is npm's signal that it cannot build a single, internally consistent dependency tree. Every package declares peerDependencies — version ranges it expects the surrounding tree to satisfy — and since npm v7 the installer treats those declarations as strict, automatically installed contracts rather than as warnings (the pre-v7 behavior). When two branches of your tree demand mutually exclusive versions of the same peer (one package wants react@^17, another wants react@^18), there is no version that satisfies both, so npm aborts before writing anything.
The conflict is structural, not cosmetic: it reflects a genuine disagreement about what version of a shared dependency should exist in node_modules. Resolving it correctly means making the tree consistent, not silencing the messenger. Because the failure is rooted in how the resolver walks declared ranges across the whole graph, understanding Dependency Resolution Explained is the foundation for every fix below. The same misclassification that triggers these errors is covered in depth in When to Use peerDependencies vs devDependencies.
It helps to distinguish the two shapes a peer conflict takes, because they call for different fixes. In a direct conflict, a package you installed declares a peer range that the framework version in your tree does not satisfy — a plugin requiring the previous major while your application is on the current one. In a transitive conflict, the incompatible peer is requested by a dependency of a dependency, so you did not choose it directly and cannot simply change a range in your own manifest. Reading the ERESOLVE output, which prints the full chain of who requires what, tells you which shape you are dealing with and therefore which lever to reach for.
The reason npm surfaces this as a hard error rather than a warning is that an unsatisfied peer is a genuine correctness risk, not a style preference. A plugin loaded against a framework version it was not written for can call APIs that no longer exist, rely on behavior that changed, or — for a framework whose identity matters — end up as a second copy that breaks context and hooks. The error is npm declining to build a tree it can prove violates a stated requirement, which is a service: it converts a latent runtime failure into an install-time signal you can act on immediately.
Resolution & Config Patch
Work the conflict in the order below. Each step is strictly preferable to the one after it.
-
Read the conflict, don't guess. The ERESOLVE block names both sides: the version
Found:in the tree and thepeer ... from ...package that disagrees. Identify which package's peer range is unsatisfiable and what version would satisfy everyone. -
Align versions at the source (preferred). If your root project pins
react@17but@acme/ui-kitrequiresreact@^18, bump the shared dependency so both ranges overlap:npm install react@^18 react-dom@^18If instead an outdated package declares the narrow peer, upgrade that package to a release whose peer range includes the version you already use:
npm install @acme/ui-kit@latestVersion alignment is the only fix that produces a tree npm itself considers valid. Always try it first.
-
Pin a single version with
overrides(when you cannot upgrade). When a transitive dependency declares an over-strict or stale peer range that the maintainer has not fixed, force the whole tree onto one version using theoverridesfield inpackage.json:{ "overrides": { "react": "$react", "react-dom": "$react-dom" } }The
$reactsyntax reuses the version you declared in your owndependencies, keeping a single source of truth. You can also pin literally:{ "overrides": { "@acme/ui-kit": { "react": "18.3.1" } } }The nested form scopes the override so only
@acme/ui-kitsees the forcedreact, which is safer than a global override. This is the right tool when the conflicting peer lives deep in the tree and collapsing it to one copy is also how you fix Deduplicating Duplicate React Versions. -
--legacy-peer-deps(last resort). This flag restores npm v6 behavior: peer dependencies are no longer auto-installed or enforced, so npm ignores the conflict and installs anyway:npm install --legacy-peer-depsIt is a last resort because it does not fix anything — it disables the check that caught a real incompatibility. You may end up running a package against a peer version it was never tested with, surfacing as runtime crashes rather than install errors. If you must use it, scope it narrowly and document why.
-
--force(almost never).npm install --forceis broader still: it overrides multiple safety checks, not just peers, and will happily install a tree npm believes is broken. Reach for it only to reproduce a problem or in a throwaway environment, never in CI or a committed setup.
After whichever step applies, regenerate the lockfile and reinstall cleanly:
rm -rf node_modules package-lock.json
npm install
When an override is the right lever, scope it as narrowly as the conflict requires and treat it as temporary. A global override that forces a framework version everywhere is a broad claim about compatibility; a scoped one that pins the version only under the conflicting dependency limits the blast radius to exactly the subtree that needs it. Either way, document why the override exists and track the upstream change that will let you remove it, so your manifest reflects deliberate, current decisions rather than a growing pile of pins whose reasons have been forgotten. After applying any override, re-run the install and inspect the graph to confirm the conflict is genuinely resolved rather than merely silenced.
CLI Validation & Debug Commands
Confirm the conflict is genuinely resolved rather than merely silenced:
# How many copies of the package exist, and where?
npm ls react
# Why is THIS version in the tree? Traces the requiring chain.
npm explain react
# Reproduce a clean, deterministic install (fails on any conflict)
rm -rf node_modules && npm ci
npm ls react should report a single version with no invalid or UNMET PEER DEPENDENCY annotations. npm explain react prints the full chain of packages that pulled the version in, which tells you exactly which dependency to upgrade or override. A successful npm ci against the committed lockfile is the strongest proof: it reinstalls from scratch and re-runs peer resolution, so a passing npm ci means the conflict is structurally gone.
The most useful debugging command is the one that shows you the conflict before you try to fix it. Running the install with increased verbosity prints the full requirement chain, and inspecting the resolved graph afterward confirms whether the fix worked. Reading the actual versions and ranges — rather than guessing — is what turns a peer conflict from a frustrating wall into a solvable puzzle: the error already names the incompatible requirements, and the graph tools show you exactly how the tree resolved, so the reconciliation is a matter of acting on information the tooling gives you rather than trial and error.
Prevention & CI Guardrails
- Fail fast in CI. Run
npm ci(notnpm install) so the pipeline reinstalls from the lockfile and surfaces ERESOLVE before merge, with no implicit--force. - Ban silent bypasses. Forbid
--legacy-peer-depsand--forcein CI scripts; if they exist, require a code comment and an issue link justifying each one. - Keep
overridesauditable. Review every entry inoverridesduring PR review — each one is a manual override of the resolver and should expire once upstream ships a fix. - Pin the package manager. Add
"packageManager": "npm@10.x"so developers and CI resolve peers identically and you don't chase version-specific ERESOLVE differences. - Catch misclassification early. Lint
package.jsonso runtime host libraries land inpeerDependenciesand tooling stays indevDependencies, the root cause of most avoidable conflicts.
The most effective prevention is keeping your own peer ranges honest and your framework versions aligned. A library you maintain should declare peer ranges as wide as it genuinely supports, so consumers on any compatible version resolve cleanly rather than hitting a spurious conflict; an over-narrow peer range is a common self-inflicted cause of ERESOLVE for your consumers. On the consuming side, keeping shared frameworks on a single, current version across the workspace means fewer packages request incompatible ranges in the first place.
Treat a suppressed conflict as technical debt to resolve, not a permanent state. If you must ship with --legacy-peer-deps to unblock an install, record why and track the upstream fix — the package with the over-strict peer range, or the provider that needs updating — so the suppression is removed once the ecosystem catches up. Running the install without the suppression flag in a scheduled CI job surfaces when the conflict has genuinely resolved upstream, so you can drop the workaround rather than carrying it indefinitely. A dependency graph that resolves cleanly without suppression flags is both more trustworthy and easier to update than one whose conflicts are permanently silenced.
Why ERESOLVE happens and what it protects
ERESOLVE fires when npm cannot find a single set of versions that satisfies every peer-dependency requirement in the tree simultaneously. A package declares a peer range — say a plugin requires a framework >=17 <18 — and another part of the tree provides a version outside that range, so no installation satisfies both constraints. npm refuses rather than install a tree it knows violates a declared requirement, which is the error protecting you from a runtime failure the peer declaration was warning about.
The error is stricter in npm 7+ than the older behavior precisely because unsatisfied peers cause real bugs — a plugin using a framework version it does not support, or two incompatible copies of a shared dependency. So ERESOLVE is a signal that a genuine version conflict exists in your dependency graph, not merely npm being fussy. The right response is to reconcile the conflict: update the package with the narrow peer range, update the provider to a version the peer accepts, or confirm the peer range is unnecessarily strict and should be widened upstream. Understanding that the error reflects an actual unsatisfiable constraint — not a tooling quirk — is what steers you toward fixing the conflict rather than suppressing the check.
Resolving without --force or --legacy-peer-deps
The tempting shortcuts — --force and --legacy-peer-deps — suppress the check rather than resolve the conflict, and they can leave you with a genuinely incompatible tree that fails at runtime instead of at install. --legacy-peer-deps restores npm's old behavior of ignoring peer conflicts, and --force overrides them outright; both trade a loud, actionable install error for a silent, latent one. Reserve them for a known-safe case where you have verified the conflict is harmless, not as a default.
The durable fixes reconcile the actual conflict. Read the error, which names the conflicting requirements, and choose the real fix: upgrade the dependency whose peer range is too narrow to a version that accepts the installed framework; downgrade or upgrade the provider to a version the peer supports; or, if the peer range is genuinely over-strict, file an issue upstream (or patch it locally) to widen it. Where a transitive peer is the problem, an overrides entry can pin a compatible version. Each of these produces a tree that actually satisfies the constraints, so the install succeeds because the conflict is gone — not because the check was bypassed. Choosing reconciliation over suppression is the difference between a resolved dependency graph and a hidden incompatibility waiting to surface as a broken hook dispatcher or a doubled framework at runtime.
How each package manager handles peer conflicts
The three package managers treat peer conflicts differently, and knowing the differences explains why the same dependency tree installs cleanly under one and errors under another. npm 7+ enforces peers strictly by default, refusing an install that violates a peer range — which is what produces ERESOLVE. pnpm also surfaces peer issues but is often more precise about which package is responsible, and its strict, symlinked layout makes a doubled framework fail loudly rather than silently. Yarn's behavior depends on the version, with Berry generally warning rather than hard-failing on peers.
This variation matters when a project moves between managers or when a contributor's local install behaves differently from CI. A tree that npm rejects with ERESOLVE might install with a warning under a different manager — but the underlying conflict is the same, and the warning is not a resolution. The right response regardless of manager is to reconcile the actual version conflict rather than rely on a particular tool's leniency, because a peer that is unsatisfied under npm's strict resolution is unsatisfied everywhere; only the loudness of the complaint differs. Pinning the package manager with packageManager and Corepack ensures everyone sees the same behavior, so a conflict surfaces uniformly rather than appearing only for the developers on the strictest tool.
A worked example: resolving a real conflict
Walking through a concrete conflict makes the reconciliation approach concrete. Suppose npm install fails with an ERESOLVE naming a charting library that declares a peer of react@^17 while your application is on react@18. The error prints the chain: your app depends on react@18, the charting library depends on react@^17, and the two cannot coexist. The question is which of the three levers applies.
The first check is whether the charting library has a newer version that supports React 18 — often it does, and upgrading it (npm install charting-lib@latest) resolves the conflict because the newer version widens its peer range. If no such version exists, the peer range may be over-cautious, in which case checking the library's issue tracker or source reveals whether React 18 actually works; if it does, a temporary overrides entry forcing the library to accept React 18, or --legacy-peer-deps with a tracked follow-up, unblocks you while you wait for an official release. If the library genuinely does not support React 18, the honest conclusion is that you cannot use that version yet, and the conflict is telling you a real incompatibility rather than a resolvable one.
The discipline the example illustrates is to read the named requirements, check whether a compatible version exists, and choose the fix that matches — upgrade, override with verification, or accept the incompatibility. This is the opposite of reflexively adding --legacy-peer-deps, which would have installed a tree with React 17 expectations against React 18, potentially breaking the charting library at runtime in ways far harder to diagnose than the install error that warned you.
Frequently Asked Questions
What is the difference between --legacy-peer-deps and --force?
--legacy-peer-deps only disables automatic installation and enforcement of peer dependencies, mimicking npm v6. --force is much broader — it overrides peer checks plus other integrity and resolution guards, and will install a tree npm considers invalid. Prefer --legacy-peer-deps if you must bypass at all, and prefer overrides over both.
Will overrides break anything?
It can. Forcing a single version means a package that requested a different one now runs against code it may not have been tested with. Scope overrides to the specific dependency that needs them (the nested form), pin to a version inside every consumer's expected range where possible, and remove the override once the upstream peer range is fixed.
Why did this work in npm v6 but break after upgrading?
npm v7+ installs and strictly enforces peerDependencies automatically; npm v6 only warned about unmet peers and never installed them. An upgrade can therefore surface a latent conflict that was always present but previously ignored. The fix is to align versions, not to permanently fall back to --legacy-peer-deps.
How do I find which package is causing the conflict?
Run npm explain <package> for the conflicted dependency. It prints every chain that requires it, so you can see which package declares the incompatible peer range and decide whether to upgrade that package or scope an override to it.
What does an ERESOLVE error actually mean?
npm cannot find a single set of versions that satisfies every peer-dependency requirement at once — a genuine version conflict where a peer range and the installed version disagree. It is protecting you from a tree that violates a declared requirement, not being fussy.
Should I use --force or --legacy-peer-deps to fix ERESOLVE?
Only for a case you have verified is harmless. Both suppress the check rather than resolve the conflict, trading a loud install error for a silent runtime one. Prefer reconciling the actual conflict — update the package with the narrow peer, update the provider, or widen an over-strict range upstream.
How do I fix a peer conflict in a transitive dependency?
Pin a compatible version with an overrides entry, so the transitive peer resolves to a version that satisfies the constraint. Verify with npm ls that the graph now resolves cleanly, rather than suppressing the check with --legacy-peer-deps.
Why does the same install fail under npm but warn under Yarn?
npm 7+ enforces peer dependencies strictly and refuses a violating tree with ERESOLVE, while some Yarn and pnpm configurations warn instead. The underlying conflict is identical — an unsatisfied peer is unsatisfied everywhere; only the loudness differs. Reconcile the actual conflict rather than relying on a tool's leniency.
A library's peer requires an older framework major than I'm on — what do I do?
First check for a newer version of the library that supports your major; upgrading it usually widens the peer range and resolves the conflict. If none exists but the library actually works with your version, use a verified overrides pin with a tracked follow-up. If it genuinely doesn't support your version, the conflict is a real incompatibility, not something to suppress.
Related
- When to Use peerDependencies vs devDependencies — classify dependencies correctly so peer conflicts never arise in the first place.
- Deduplicating Duplicate React Versions — the duplicate-copy problem that
overridesalso solves. - Lockfile Management Strategies — why
npm ciagainst a committed lockfile is the right CI gate for catching ERESOLVE. - Understanding package.json Fields — how
overrides,dependencies, andpeerDependenciesinteract in the manifest.