Back to core workflows Fix dependency resolution Tune package metadata Validate before publishing

Keeping Workspace Dependency Versions in Sync with syncpack

In a monorepo, the same external dependency is declared in many package.json files, and over time the ranges drift: react at ^18.2.0 in one app and ^18.3.1 in another, typescript pinned exactly in some packages and ranged in others, zod two majors apart. Drift causes duplicate installs, confusing type errors between packages, and upgrade pull requests that touch one package while leaving the rest behind. syncpack finds and fixes these mismatches across every manifest, and enforces rules about how versions are written. This guide sets it up, defines useful policies, and adds it to CI.

The symptoms of version drift

Drift rarely breaks anything on the day it happens. The costs accumulate:

$ pnpm why react --recursive
@acme/web      react 18.3.1
@acme/admin    react 18.2.0
@acme/ui       react 18.3.1 (peer)

Two React versions in one workspace means two copies in the store, and — worse — two copies in any bundle or test run that crosses the boundary, producing the "invalid hook call" errors covered in Deduplicating Duplicate React Versions. Type errors appear when @acme/ui built against @types/react@18.3 is consumed by an app on 18.2 types. And every dependency bot run produces a scatter of pull requests, one per package, instead of a single coordinated upgrade.

How syncpack sees the workspace

syncpack reads every package.json matched by your workspace configuration, collects every dependency declaration across dependencies, devDependencies, peerDependencies, overrides and similar fields, and groups them by package name. A group whose members disagree is a mismatch. Rules — "version groups" and "semver groups" — decide what counts as agreement and how ranges should be written.

How syncpack finds and fixes mismatches syncpack reads every workspace manifest, groups dependency declarations by name, applies version and semver group rules, and reports or rewrites mismatched ranges. read manifests root, apps/*, packages/* group by name react: 3 declarations apply rules version groups, semver groups lint or fix report or rewrite package.json
Version groups decide which declarations must agree; semver groups decide how each range is written.

Setting it up

pnpm add -D -w syncpack

A configuration that covers the common needs, in .syncpackrc.json:

{
  "$schema": "https://unpkg.com/syncpack@latest/dist/schema.json",
  "source": ["package.json", "apps/*/package.json", "packages/*/package.json"],
  "versionGroups": [
    {
      "label": "Use workspace protocol for internal packages",
      "dependencies": ["$LOCAL"],
      "dependencyTypes": ["dev", "prod"],
      "pinVersion": "workspace:*"
    },
    {
      "label": "Peer dependencies may use wide ranges",
      "dependencyTypes": ["peer"],
      "isIgnored": true
    },
    {
      "label": "Legacy admin app stays on React 18 until migration",
      "packages": ["@acme/admin"],
      "dependencies": ["react", "react-dom", "@types/react", "@types/react-dom"],
      "isIgnored": true
    }
  ],
  "semverGroups": [
    {
      "label": "Tooling is pinned exactly",
      "dependencies": ["typescript", "vite", "vitest", "eslint"],
      "range": ""
    },
    {
      "label": "Everything else uses caret ranges",
      "range": "^"
    }
  ]
}

Key ideas:

  • Version groups partition dependencies. By default all declarations of a name must match; groups create exceptions (ignored, pinned, or allowed to differ within a subset of packages). The first matching group wins, so order groups from specific to general.
  • $LOCAL matches packages that live in the workspace, so internal dependencies can be forced to the workspace protocol.
  • Semver groups control the range style — exact pins for build tools whose minor releases change output, carets for libraries.

Designing version groups for a real workspace

The default rule — every declaration of a package must agree — is right for most dependencies, but real repositories need a few deliberate exceptions. Designing them up front keeps the configuration small and readable.

Framework islands. An application mid-migration may need an older major of a framework while the rest of the repository moves on. Give it a version group scoped with packages so it can differ, and label the group with the migration ticket. When the migration finishes, delete the group and let fix align everything.

Deliberately different tools. A package that tests against multiple TypeScript versions, or a codemod package that must depend on an old parser, is a legitimate exception. Scope it narrowly to that package and dependency.

Banned dependencies. syncpack can also forbid packages: a version group with isBanned: true for moment or request fails lint wherever they appear, which is a lightweight way to enforce architectural decisions.

Peer dependencies. Peers describe compatibility ranges and should usually be excluded from alignment, or given their own group that checks they stay wide enough to include the version you actually install.

Order matters because the first matching group wins. Put the narrowest groups — a single package and dependency — first, then broader ones, and finish with the default rule.

Range style and why it matters

Semver groups enforce how versions are written, independent of which version is chosen. Exact pins (5.7.2) for build tools prevent surprise output changes from minor releases; caret ranges (^3.24.1) for runtime libraries let the lockfile pick up patches while keeping the declared minimum clear. Mixing styles for the same dependency across packages is itself a form of drift: typescript: "5.7.2" in one package and "^5.7.2" in another can resolve to different versions after the next install. A single semver rule per dependency removes that ambiguity, and syncpack lint reports any package that breaks it.

Running it

syncpack's command names changed across majors; recent releases use lint, fix, format and update, while older ones used list-mismatches and fix-mismatches. Check npx syncpack --help for your version.

# Report mismatches and range-style violations (non-zero exit on problems)
npx syncpack lint

# Rewrite manifests so every group agrees (uses the highest version by default)
npx syncpack fix

# Sort and normalise package.json formatting across the workspace
npx syncpack format

# Then refresh the lockfile
pnpm install
A mismatch report and the fixed manifests Left panel lists mismatched versions of react, typescript and zod across packages; right panel shows each aligned to one version after fixing. syncpack lint react ^18.2.0 apps/admin ^18.3.1 apps/web typescript 5.6.3 packages/ui ^5.7.2 packages/api zod ^3.22.4 packages/api ^3.24.1 apps/web after syncpack fix react ^18.3.1 (all) typescript 5.7.2 (pinned, all) zod ^3.24.1 (all) pnpm install -> one copy of each in the store
fix aligns every declaration in a group to the highest version, then the lockfile is refreshed.

Alternatives and complements

syncpack is not the only way to keep versions aligned, and it combines well with the others:

  • pnpm catalogs define a version once in pnpm-workspace.yaml and let manifests reference it with catalog:. That removes drift at the source for the dependencies you put in the catalog; syncpack can still enforce rules for everything else. See Sharing Dependency Versions with pnpm Catalogs.
  • Yarn constraints express the same rules in JavaScript for Yarn Berry workspaces.
  • Renovate's grouping keeps versions aligned going forward by upgrading every declaration of a package in one pull request, but it does not fix existing drift.
Tools for aligning dependency versions Compares syncpack, pnpm catalogs, Yarn constraints and Renovate grouping on fixing existing drift, preventing new drift, package manager support and range-style rules. syncpack pnpm catalogs Yarn constraints Renovate groups Fixes existing drift yes after migration yes no Blocks new drift in CI lint by design yes no Package managers any pnpm only Yarn only any Range-style rules semver groups no scriptable rangeStrategy
Catalogs prevent drift for listed packages; syncpack audits and fixes the whole workspace under any package manager.

Running syncpack alongside dependency bots

Dependency bots and syncpack can fight if they are configured independently: the bot bumps zod in one package, syncpack lint fails because the other packages still declare the old version, and the pull request is stuck. Configure the bot to update every declaration of a package together — Renovate does this when dependencies are grouped by name across the monorepo, and Dependabot's groups setting can approximate it — and add syncpack lint to the bot's required checks. When the bot and the linter agree on the rule, upgrade pull requests arrive already aligned. For a failing bot pull request, running syncpack fix and pnpm install on the branch is usually all it takes to bring the remaining packages along.

Worked example: aligning twelve packages before a React upgrade

Before upgrading to React 19, a team runs syncpack lint and finds React at three different minor versions and @types/react at four. Upgrading from that state would require touching every package individually and would leave the workspace inconsistent midway. They first run syncpack fix to align everything on the current highest 18.x, refresh the lockfile, and merge that alignment on its own. The React 19 upgrade then becomes a single pull request that changes one version in every manifest at once — or one line in a catalog after they adopt catalogs — with syncpack lint in CI guaranteeing no package is left behind.

Prevention and CI/CD guardrails

  • Run syncpack lint as a required check so new mismatches never merge.
  • Keep deliberate exceptions in named version groups with labels explaining why, instead of ignoring the whole tool.
  • Refresh the lockfile after every fix, and commit both together.
  • Move stable, widely shared dependencies into catalogs where your package manager supports them.

Frequently Asked Questions

Does syncpack change the lockfile? No. It edits package.json files only. Run your package manager's install afterwards to update the lockfile.

Which version does fix choose? By default the highest version found in the group. Version groups can instead pin a specific version or prefer the lowest, which is useful when a package cannot yet move forward.

Should peer dependency ranges match dependency ranges? Usually not. Peer ranges describe compatibility and are often wide (^18 || ^19), while dependency ranges describe what you install. Keep peers in their own ignored or separately ruled group.

Does syncpack work with npm and Yarn workspaces? Yes. It reads package.json files from the source globs you give it, independent of the package manager.

Can syncpack update dependencies to their latest versions? Recent releases include an update command that checks the registry and offers newer versions across every manifest at once, keeping groups aligned while upgrading. It complements rather than replaces a dependency bot, which adds scheduling, changelogs and pull requests.

Related

Dependency Auditing and Automated Updates