If you dive into the specification of package.json you might be surprised to learn that next to the options dependencies and devDependencies there are also the options bundleDependencies, optionalDependencies and peerDependencies. Recently we have been migrating our main application to an architecture with multiple frontends based on NextJS. Since we have shared components and logic we expose these as packages with peer dependencies. And I found that a good number of my colleagues are unfamiliar with peer dependencies.
What are peer dependencies
In order to explain peer dependencies, it is beneficial to first look at how NPM and PNPM work. Whenever you install a dependency in your project, this dependency can have other dependencies (these are typically referred to as non-root dependencies or transitive dependencies). Within the scoping model of (P)NPM these dependencies are tied to this specific dependency. The big benefit of this is that you hardly ever have version conflicts in your project, but a big downside of this is that you cannot guarantee that the dependency and your project will have the same version of a dependency, and you might get a good amount of duplicate dependencies in your node_modules folder. Let's look at an example.
NB. For the example I am using existing dependencies that I found in one of the projects of my employer. These dependencies are deliberately outdated, this will allow you to reproduce this yourself. The examples use NPM 10.9.4 and PNPM 10.20.0.
npm install chalk@4.1.2 wrap-ansi@8.1.0 ansi-styles@2.2.1 --save-dev
We can now use npm ls ansi-styles to get a list of usages of this package:
├── ansi-styles@2.2.1
├─┬ chalk@4.1.2
│ └── ansi-styles@4.3.0
└─┬ wrap-ansi@8.1.0
└── ansi-styles@6.2.3
We have installed three different versions of the ansi-styles package. How NPM stores this is as follows:
└─┬ node_modules
├── ansi-styles // version 2.2.1
├─┬ chalk
│ └─┬ node_modules
│ └── ansi-styles // version 4.3.0
└─┬ wrap-ansi
└─┬ node_modules
└── ansi-styles // version 6.2.3
We can do the same in PNPM - pnpm install chalk@4.1.2 wrap-ansi@8.1.0 ansi-styles@2.2.1 --save-dev - with a similar result for pnpm why ansi-styles:
ansi-styles 2.2.1
chalk 4.1.2
└── ansi-styles 4.3.0
wrap-ansi 8.1.0
└── ansi-styles 6.2.3
In the folder structure we see that we still actually have three versions of ansy-styles installed:
└─┬ node_modules
├─┬ .pnpm
│ ├── ansi-styles@2.2.1
│ ├── ansi-styles@4.3.0
│ ├── ansi-styles@6.0.1
│ ├─┬ chalk@4.1.2
│ │ └─┬ node_modules
│ │ └── ansi-styles -> ../../ansi-styles@4.3.0/node_modules/ansi-styles
│ └─┬ wrap-ansi@8.1.0
│ └─┬ node_modules
│ └── ansi-styles -> ../../ansi-styles@6.0.1/node_modules/ansi-styles
└── ansi-styles -> .pnpm/ansi-styles@2.2.1/node_modules/ansi-styles
Although PNPM makes heavily use of symlinks to save disk space, but we still have three copies of the ansi-styles package. Even the pnpm dedupe won't change this.
Now we need to get a little bit hypothetical. Let's say that wrap-ansi expects an object that is defined in ansi-styles as argument to a function. This means that we need to instantiate this object in our application and pass this to wrap-ansi. And let's say that between version 2.2.1 of ansi-styles - which we have defined in our project - and version 4.3.0 of ansi-styles - which is required by chalk - the type definition of this object has changed. There's a difference of two major versions, so backwards incompatibility is expected. What will happen? Our application will probably break at runtime.
This is where peer dependencies come into play; a dependency that is marked as a peer dependency is required to be installed next to the dependency, as a peer. What if in this example chalk marks ansi-styles@^4.0.0 as peer dependency. Then NPM would have thrown an error when we would try to install ansi-styles@2.2.1 as 2.2.1 does not match the version range ^4.0.0. So, peer dependencies allow you to guarantee that a transitive dependency is the same version range as the non-transitive dependency of the application.
A concrete example of this is NextJS. NextJS is a framework that uses React, and as such the package next has a peer dependency on react, so when we want to install NextJS we als need to install React - and a number of additional packages. Be aware! NPM and PNPM do not automatically install peer dependencies. They will happily let you add next as a dependency without adding react as dependency, and without giving an error message. So, this is something you will need to do yourself. Installing NextJS is done as follows:
npm install next@^15 react@^19 react-dom@^19 @opentelemetry/api@^1.1 @playwright/test@^1.51.1 babel-plugin-react-compiler sass@^1.3 --save
(P)NPM is an outlier in this behavior compared to package managers of other languages. With package managers like Composer (PHP), pip (Python) and NuGet (.NET) dependencies are by default peer dependencies. That means that in those package managers it is not possible to have multiple versions of the same dependency in your application1.
When to use peer dependencies
Peer dependencies are used when your application and one or more dependencies will be relying on the same definitions of methods or objects. A good rule of thumb is: when instantiation and usage are in two separate parts of the code then you might want to consider using peer dependencies. Let's highlight three concrete examples to illustrate this.
Shared type definitions
We currently operate in three countries, but in some countries we have multiple activities. Within our application landscape these activities are indicated as subsidiaries. For the majority of code it is completely irrelevant how these subsidiaries are represented, but they do need to have knowledge about subsidiaries, for example when we want to make an API call to retrieve information for the current subsidiary. We need to make sure that the subsidiary in the application is defined in the same way as it is in the package that makes the API call - let's call this package product-information with a single method with the following signature:
type GetProduct = (sku: string, subsidiary: Subsidiary) => Promise<Product>;
Where should the type Subsidiary be defined? And what if we also had another package to retrieve contact information - let's calls this package contact-information? That is also localized per subsidiary. It is not desirable to have separate definitions for the same entity. For this we introduced an internal package called constants. That package contains the definition of Subsidiary, and by making this package a peer dependency of product-information and or contact-information we guarantee that the definition of Subsidiary is the same in all the placed we need it.
Plugins
We are using Axios for HTTP requests, and Apollo Client for GraphQL requests. For both of these clients we have built extensions/plugins, for example to send correlation headers2. For such an extension you are dependent on the version of the client. As such we mark axios a peer dependency of the extensions for Axios, and apollo-client of the extensions for Apollo Client. That way we can guarantee that the plugin will be compatible with the version of the client.
Sending these headers is not enough, they need to be added to the logs as well. That's why we also have an extension for our logger that automatically adds any correlation values to all logs. This extension therefore also has a peer dependency on the package that contains the logger.
NB. These extensions also have a peer dependency on a package that contains a type definition for CorrelationContext, which is another example of the previous use case.
Frameworks
We have recently migrated away from our monolithic approach for our main application in favor of a decentralized approach, where separated parts of our application are handled by separate frontends. We have adopted NextJS as our framework of choice for these frontends. Now, just because we know that our application exists of multiple smaller frontends does not mean that our customers need to have this knowledge as well. In fact, for our customers we want to present our application as exactly that: a unified application.
To achieve this idea of a single application we have introduced a slew of packages that build parts of the application; there's a package for the shared layout items - header, footer -, there's a package with all design elements (our design system), and there are packages for things like product cards. All these packages use features defined in both NextJS, but also in React - which is the foundation for NextJS. All these packages can only be guaranteed to work when they know what version of NextJS and React is being used. That is why for all these packages next and react are peer dependencies.
For the latter example we can see how NPM responds to a deliberate violation of the peer dependencies:
> npm i next@^15.0 react@^15.0 --save
npm error code ERESOLVE
npm error ERESOLVE unable to resolve dependency tree
npm error
npm error While resolving: npm-peer-dependencies@1.0.0
npm error Found: react@15.7.0
npm error node_modules/react
npm error react@"^15.0" from the root project
npm error
npm error Could not resolve dependency:
npm error peer react@"^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0" from next@15.5.6
npm error node_modules/next
npm error next@"^15.0" from the root project
But let's have a look at the folder structure when we use the correct versions:
npm i next@^15 react@^19 next-auth@^4 --save
I have added next-auth for illustration purposes, because next-auth also has a peer dependency on next, and this way we can illustrate that only one version of next is installed:
└─┬ node_modules
├── next
├── next-auth
└── react
So, instead of having potentially multiple version of the same package, we force NPM - and PNPM - to have a single version of the same package.
A note on PNPM
PNPM by default emits a warning when there's an issue with peer dependencies, but it does not actually exit with a non-success exit code:
> pnpm add next@^15.0 react@^15.0 --save
Progress: resolved 70, reused 33, downloaded 8, added 23, done
WARN Issues with peer dependencies found
.
├─┬ react-dom 19.2.0
│ └── ✕ unmet peer react@^19.2.0: found 15.7.0
└─┬ next 15.5.6
├── ✕ unmet peer react@"^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0": found 15.7.0
└─┬ styled-jsx 5.1.6
└── ✕ unmet peer react@">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0": found 15.7.0
> echo $?
0
And also when installing the dependencies we PNPM will respond with a non-error exit code:
> pnpm install --frozen-lockfile
Lockfile is up to date, resolution step is skipped
Already up to date
> echo $?
0
The consequence of this is that automated pull requests from Dependabot or Renovate might pass inspections with invalid peer dependencies3. Looking at some issues on the PNPM repository - e.g. #6893 and #7087 - this is a deliberate choice, and not a high priority issue. There is a setting called strictPeerDependencies for this that you can enable for your repository:
echo 'strict-peer-dependencies=true' >> .npmrc
The problem is that this setting does not work very consistently; it will not work when running pnpm add nor when running pnpm install with the --frozen-lockfile flag - which should be the default in your CI/CD pipelines. For that to work you need to run an additional command:
> pnpm install --resolution-only --prefer-frozen-lockfile
ERR_PNPM_PEER_DEP_ISSUES Unmet peer dependencies
.
├─┬ react-dom 19.2.0
│ └── ✕ unmet peer react@^19.2.0: found 15.7.0
└─┬ next 15.5.6
├── ✕ unmet peer react@"^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0": found 15.7.0
└─┬ styled-jsx 5.1.6
└── ✕ unmet peer react@">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0": found 15.7.0
> echo $?
1
NB. You might notice the missing --frozen-lockfile flag and the presence of the --prefer-frozen-lockfile flag. There's an issue open on the PNPM repository about this: #8433. What my experiments have shown is that this command will correctly use the lockfile for resolution.
Keep in mind that this does not actually install the dependencies! This will only perform a resolution step. You will still need to run pnpm install --frozen-lockfile after running this command, but it will exit with a non-success exit code, and thus any code inspections should fail accordingly.
Conclusion
Looking at dependency managers for similar (web focused) languages the JavaScript ecosystem stands out; where Composer, Nuget and Pip install a package only once, NPM, PNPM and Yarn scope a package to their direct dependency. This can become an issue in the scenarios where you reuse definitions between one of multiple packages. For these scenarios you can use the concept of peer dependencies.
Proper handling of peer dependencies will give you guarantees about versions of packages being used within your application, but also guarantees for the maintainer of packages that their package works as expected. Neither NPM and PNPM automatically install peer dependencies, nor do they notify you of missing peer dependencies. NPM does give you the guarantee that the versions of peer dependencies are valid by default, but for PNPM some extra steps are required.
-
Of course, with enough trickery you might be able to install two versions of the same package, but it is far from trivial. ↩
-
Correlation headers are headers that are sent with (internal) interactions. When added to logs it allows for correlating all the logs/operations across applications for a single request. The W3C has a recommendation in Trace Context that has a similar purpose. ↩
-
Of course every codebase has proper test coverage, so the inspections would fail in case invalid peer dependencies were to cause problems, right?! ↩
Top comments (0)