Syncpack: keep monorepo dependency versions in sync (v15 guide)
By Léo Pradel • • Updated • 7 min read
Syncpack lints and fixes dependency versions across every package.json in a monorepo. Install it at the workspace root, run syncpack lint to see mismatches and syncpack fix to align them and run syncpack lint on the CI.
The problem syncpack solves
One app depends on [email protected], a shared UI library on ^18.3.1, a package on 19.0.0. And now your monorepo has three versions of React in node_modules, and the apps bundles grow and can break at runtime. TypeScript errors between packages because types differs by a major version and other various issues.
Syncpack treats the versions declared across the monorepo as one rule and checks it against rules you define. By default, every package must use the highest version of a dependency found anywhere in the repo.
Install
Add it as a dev dependency at the root of the workspace:
pnpm add -Dw syncpack
# or
npm install --save-dev syncpack
# or
yarn add -D syncpack
# or
bun add -d syncpackLinting and formatting
Syncpack discovers packages from pnpm, npm and Yarn workspaces. Start with the read-only commands:
# Every dependency and the versions it is used with, sorted by usage
pnpm syncpack list --sort count
# Only report problems, exit code 1 if there are any
pnpm syncpack lintlint groups its output by dependency and shows each version alongside the packages that declare it. The status next to each instance tells you what syncpack found, a banned dependency, two ranges that do not overlap, etc.
When you are happy with what it reports:
# Preview what would change
pnpm syncpack fix --dry-run
# Write the changes to package.json files
pnpm syncpack fix
# Then update the lockfile
pnpm installFormatting is a separate command. syncpack format sorts top-level fields in a predictable order. syncpack format --check reports without writing.
Writing a .syncpackrc
Syncpack looks for .syncpackrc (JSON) or .syncpackrc.json / .yaml / .js / .ts at the root, or syncpack.config.ts and friends. You only declare what differs from the defaults.
A good starting config for a pnpm monorepo:
// .syncpackrc.json
{
"$schema": "./node_modules/syncpack/schema.json",
"versionGroups": [
{
"label": "Local packages use the workspace protocol",
"dependencies": ["$LOCAL"],
"dependencyTypes": ["dev", "prod"],
"pinVersion": "workspace:*",
},
],
"semverGroups": [
{
"label": "Exact versions in production dependencies",
"dependencyTypes": ["prod"],
"range": "",
},
{
"label": "Caret ranges everywhere else",
"dependencyTypes": ["dev", "peer"],
"range": "^",
},
],
}Two concepts carry most of the weight: version groups decide which version a dependency should have, semver groups decide which range prefix (^, ~ or exact) it should use.
Version groups
versionGroups is an ordered list of rules. Syncpack walks it top to bottom and the first group that matches wins, so put specific rules before general ones. Anything that matches no group lands in the default "highest semver" group. Each rule can be scoped with dependencies (names or globs such as @types/**), dependencyTypes, packages (the name field, not the folder) and specifierTypes. Omit a field and it matches everything.
The key you set decides what the group does:
| Group type | Key | Use it when |
|---|---|---|
| Highest semver (default) | none, or "preferVersion": "highestSemver" | Everyone should move to the newest version in use |
| Lowest semver | "preferVersion": "lowestSemver" | A conservative package must not be forced forward |
| Pinned | "pinVersion": "18.14.2" | One version is mandatory everywhere |
| Snapped to | "snapTo": ["mobile-app"] | One package is the source of truth |
| Same range | "policy": "sameRange" | Ranges only need to overlap |
| Same minor | "policy": "sameMinor" | Major and minor must match, patch may differ |
| Range only | "policy": "semverRangeOnly" | Enforce the prefix, leave the numbers alone |
| Catalog | "policy": "catalog" | Versions must come from a pnpm or Bun catalog |
| Banned | "isBanned": true | The dependency must not be used |
| Ignored | "isIgnored": true | Syncpack should not look at it |
Semver groups
semverGroups use the same matching fields and the same first-match rule, with a single range key: "" for exact, ^ or ~. A common setup is exact versions in dependencies and ranges elsewhere:
{
"semverGroups": [
{ "dependencyTypes": ["prod"], "range": "" },
{ "dependencyTypes": ["dev"], "range": "~" },
{ "dependencyTypes": ["peer"], "range": "^" }
]
}Dependency types
dependencyTypes is where a dependency is declared:
| Type | Where it lives |
|---|---|
prod | dependencies |
dev | devDependencies |
peer | peerDependencies |
overrides | overrides in package.json |
pnpmOverrides | overrides in pnpm-workspace.yaml |
resolutions | resolutions (Yarn) |
local | the version field of your own packages |
pnpmCatalog, pnpmCatalog:name | catalog and catalogs.name in pnpm-workspace.yaml |
bunCatalog, bunCatalog:name | catalog and catalogs.name in the root package.json |
The catalog types appear automatically when syncpack detects catalogs. Negation works too: "dependencyTypes": ["!peer"].
Recipes
Each one is an entry in versionGroups, most specific first.
Pin a dependency everywhere:
{
"label": "TypeScript is pinned until the v7 migration is done",
"dependencies": ["typescript"],
"pinVersion": "5.9.3"
}Ban a dependency, lint fails on every package that still declares it and fix removes it:
{
"label": "Use date-fns instead of moment",
"dependencies": ["moment"],
"isBanned": true
}Make one package the source of truth, snapTo takes package names in fallback order:
{
"label": "Everyone follows the versions used by the web app",
"dependencies": ["react", "react-dom", "next"],
"snapTo": ["web"]
}Allow one package to lag behind, it is excluded because the group is listed before the default one:
{
"label": "The legacy admin stays on React 18 for now",
"dependencies": ["react", "react-dom"],
"packages": ["admin-legacy"],
"isIgnored": true
}Update dependencies
syncpack update talks to the npm registry and bumps the versions in your package.json files and catalog entries:
pnpm syncpack update --check # list outdated, exit 1 if any
pnpm syncpack update --target minor # only minor and patch bumps
pnpm syncpack update --interactive # pick the updates you want
pnpm syncpack update --dependency-types pnpmCatalog # only the catalogBy default versions published less than a day ago are skipped (minimumReleaseAge, in minutes, default 1440), since compromised packages are usually pulled within hours. If your .syncpackrc does not set it, the value from pnpm-workspace.yaml is used, so the setting from Securing your pnpm supply chain applies here too.
Run it in CI
lint and format --check exit with code 1 on problems, so they are plain steps in your pipeline:
# .github/workflows/ci.yml
jobs:
dependencies:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version-file: package.json
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm syncpack lint
- run: pnpm syncpack format --checkYou can also run syncpack lint as a pre-commit hook on staged package.json files, and syncpack update --check in a weekly job to see what drifted from the registry.
Migrating from syncpack 12 or 13
Syncpack 14 merged the old one-command-per-check CLI into lint / fix and removed the config options that duplicated version groups:
| Before (v12/13) | After (v14+) |
|---|---|
syncpack list-mismatches, lint-semver-ranges | syncpack lint |
syncpack fix-mismatches, set-semver-ranges | syncpack fix |
syncpack lint (also checked formatting) | syncpack format --check |
syncpack prompt | removed |
--types, --specs, --filter | --dependency-types, --specifier-types, --dependencies |
dependencyTypes, specifierTypes in the rc file | a version group with isIgnored: true, or the CLI flags |
lintFormatting, lintSemverRanges, lintVersions | removed, always on |
pnpm.overrides in package.json (v15) | overrides in pnpm-workspace.yaml, or pnpmOverridesLegacy |
Conclusion
Syncpack is one of those tools that is easy to set up, but saves a lot of time and headaches on the long term. Install syncpack at the root, run fix once and put lint in CI.