Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Speed up type-checking

Sharing Dependency Versions with pnpm Catalogs

pnpm catalogs let a workspace define a dependency's version once, in pnpm-workspace.yaml, and have every package reference it with the catalog: protocol. Upgrading React, TypeScript or Zod across thirty packages becomes a one-line change, version drift between packages becomes impossible for catalogued dependencies, and merge conflicts in individual manifests largely disappear. This guide sets up default and named catalogs, migrates an existing workspace to them, explains how catalog: is resolved and published, and covers the rules that keep catalogs from becoming a new source of confusion.

The problem catalogs solve

Without catalogs, each package declares its own range for shared dependencies, and the ranges drift:

$ grep -h '"react":' apps/*/package.json packages/*/package.json | sort | uniq -c
      4     "react": "^18.3.1",
      2     "react": "^18.2.0",
      1     "react": "18.3.1",

Drift causes duplicate installs, confusing type errors between packages and scattered upgrade pull requests. Tools such as syncpack detect and fix drift after it happens, as covered in Keeping Workspace Dependency Versions in Sync with syncpack. Catalogs prevent it structurally: there is only one place the version can be written. They are available from pnpm 9.5 onwards, and pnpm 10 extends them with configuration options that control how new dependencies are added. Because a catalog is part of pnpm-workspace.yaml, it is reviewed like any other workspace configuration change, and every version bump is a one-line diff that is easy to read. The broader dependency model is covered in Cross-Package Dependency Management.

One catalog, many references The default catalog in pnpm-workspace.yaml defines react, typescript and zod; apps and packages reference them with catalog: instead of version ranges. pnpm-workspace.yaml catalog: react ^18.3.1, typescript 5.7.2 apps/web react: catalog: apps/admin react: catalog: packages/ui react: catalog: (peer + dev) packages/forms zod: catalog: tooling/scripts typescript: catalog:
Every catalog: reference resolves to the single version defined in pnpm-workspace.yaml.

Defining catalogs

Catalogs live in pnpm-workspace.yaml. The default catalog uses the catalog key; named catalogs go under catalogs:

packages:
  - "apps/*"
  - "packages/*"

catalog:
  react: ^18.3.1
  react-dom: ^18.3.1
  typescript: 5.7.2
  zod: ^3.24.1
  vitest: ^2.1.8

catalogs:
  react19:
    react: ^19.0.0
    react-dom: ^19.0.0
  legacy:
    webpack: ^4.47.0

Reference them from any package manifest:

{
  "name": "@acme/web",
  "dependencies": {
    "react": "catalog:",
    "react-dom": "catalog:",
    "zod": "catalog:"
  },
  "devDependencies": {
    "typescript": "catalog:",
    "vitest": "catalog:"
  }
}

catalog: (or catalog:default) uses the default catalog; catalog:react19 uses the named one. A package migrating early to React 19 switches its two references to catalog:react19 and every other package stays on the default.

How catalog: resolves and publishes

At install time, pnpm replaces each catalog: reference with the catalog's range before resolution, so the lockfile records the resolved version once per catalog entry. The lockfile also stores a snapshot of the catalogs, which is why changing a catalog entry without reinstalling fails frozen installs with a configuration mismatch — see Fixing pnpm ERR_PNPM_OUTDATED_LOCKFILE in CI.

At publish time, pnpm pack and pnpm publish rewrite catalog: into the concrete range, exactly as they do for workspace:. The published manifest for @acme/web would contain "react": "^18.3.1". As with the workspace protocol, publishing with npm instead of pnpm ships the literal catalog: string and breaks consumers.

The life of a catalog reference A manifest says catalog:, pnpm substitutes the catalog range during install and records it in the lockfile, and pack rewrites it to the concrete range in the published manifest. react: catalog: in apps/web/package.js on install substitute ^18.3.1 from the catalog lockfile records catalog snapshot + version pnpm pack published as react: ^18.3.1
catalog: is resolved at install time and rewritten at publish time; consumers never see it.

Migrating an existing workspace

  1. Find the shared dependencies. Anything declared in three or more packages is a good candidate. syncpack's list output, or a short script over manifests, gives you the counts.
  2. Pick the version for each. Usually the highest range already in use; align first if the spread is large.
  3. Write the catalog in pnpm-workspace.yaml.
  4. Replace ranges with catalog: in every manifest. A short script that walks the manifests and swaps the range for each catalogued name is the most reliable way; review the diff to confirm only catalogued entries changed.
  5. Reinstall and commit pnpm-workspace.yaml, every manifest and pnpm-lock.yaml together.
  6. Enforce it. pnpm's catalogMode setting (in recent pnpm 10 releases) can make pnpm add write catalog: automatically for dependencies that already exist in a catalog, or refuse ranges that bypass the catalog.
# pnpm-workspace.yaml
catalogMode: prefer      # or "strict" to reject non-catalog ranges for catalogued packages

Where catalogMode is not available, syncpack can enforce that catalogued dependencies always use catalog: by pinning a version group to that string.

What belongs in a catalog

Not every dependency benefits. A good catalog contains:

  • Framework and runtime libraries that must be identical across packages to avoid duplicate instances — React, Vue, a GraphQL client.
  • Toolchain packages where consistency matters — TypeScript, the test runner, the bundler, ESLint.
  • Widely shared utilities — Zod, date libraries, logging.

Leave out dependencies used by one package only (a catalog entry adds indirection for no benefit) and peer dependency ranges of published libraries, which should stay wide and explicit. A published component library typically declares "react": "^18.2.0 || ^19.0.0" in peerDependencies directly, and "react": "catalog:" in devDependencies for its own development.

Catalogs compared with other version-alignment approaches Compares pnpm catalogs, syncpack alignment and overrides on where the version is defined, prevention of drift, effect on published packages and scope. pnpm catalogs syncpack alignment pnpm overrides Where the version lives one catalog entry every manifest one override Prevents drift structurally by CI check forces a version Affects transitive deps no no yes Published manifests rewritten to ranges ranges as written not published
Catalogs define versions for your own declarations; overrides force versions deep in the tree; syncpack audits either way.

Catalogs and caching task runners

Moving versions into pnpm-workspace.yaml changes where dependency changes show up, and task runners need to know. Turborepo and Nx compute a package's cache key partly from its dependencies. When a package declares "react": "catalog:", its own package.json does not change when the catalog bumps React from 18.3.1 to 18.3.2 — only pnpm-workspace.yaml and the lockfile do. Both tools hash the resolved dependency versions from the lockfile, so a catalog bump still invalidates the right caches, but custom input globs that list only package.json and src/** would miss it. If you have customised task inputs, include pnpm-workspace.yaml in the global inputs, or rely on the tools' default lockfile-aware hashing.

The same reasoning applies to affected detection. A pull request that only edits a catalog entry changes no package manifest, so tools that compute affected packages from changed files alone see nothing affected. Turborepo and Nx both analyse lockfile changes to find packages whose resolved dependencies changed; pnpm's --filter "...[origin/main]" looks at changed files only, so for catalog-only changes run the full test suite or add a CI rule that treats changes to pnpm-workspace.yaml as affecting everything.

Common mistakes

A few mistakes come up repeatedly in the first weeks after adopting catalogs. The most common is committing a catalog change without the lockfile, which fails every frozen install in CI; a pre-commit hook that runs pnpm install --lockfile-only when pnpm-workspace.yaml is staged prevents it. The second is adding one-off dependencies to the catalog "for consistency", which turns the catalog into a long list nobody reviews; keep it to genuinely shared packages. The third is catalogues for peers of published libraries, discussed above, which silently narrow what consumers may install. And the fourth is forgetting that named catalogs need cleaning up: once a migration catalog such as react19 has no remaining references, delete it so the next reader is not left wondering which one is current.

Worked example: a React 19 migration package by package

A workspace with nine applications wants to move to React 19 gradually. The team adds a react19 named catalog next to the default one. Each application migrates in its own pull request by changing four references — react, react-dom, @types/react, @types/react-dom — from catalog: to catalog:react19, fixing whatever breaks, and merging. Shared component packages keep React in devDependencies from the default catalog and a wide peer range, so they are tested against React 18 until the last application moves. When it does, the team updates the default catalog to React 19, switches every catalog:react19 reference back to catalog:, deletes the named catalog and reinstalls. At no point did two applications disagree about which React 19 version they used.

Prevention and CI/CD guardrails

  • Commit catalog changes with the lockfile; CI's frozen install rejects anything else.
  • Enforce catalog: usage with catalogMode: strict or a syncpack rule, so new packages cannot bypass the catalog.
  • Publish only with pnpm, so catalog: is rewritten in packed manifests.
  • Configure dependency bots to update catalog entries: Renovate supports pnpm catalogs and opens a single pull request per catalog entry.

Frequently Asked Questions

Do catalogs work with npm or Yarn? No. Catalogs are a pnpm feature. Yarn Berry users can approximate them with constraints; npm users can use syncpack.

Can I use catalog: in peerDependencies? Yes, but it publishes as the catalog's range, which is usually narrower than a library's real compatibility. For published libraries, prefer explicit wide peer ranges.

What happens if a catalog entry is missing? The install fails with an error naming the missing entry, so a typo in catalog:react19 is caught immediately.

How do I see which packages use a catalog entry? Search the manifests for the reference — grep -rln '"react": "catalog:react19"' apps packages — or use pnpm why react -r, which lists every importer and the version it resolved, confirming they all share one version.

Can catalogs pin exact versions? Yes. A catalog entry is any valid range, so typescript: 5.7.2 pins exactly while zod: ^3.24.1 allows compatible updates in the lockfile.

Related

Cross-Package Dependency Management