# Syncpack: keep monorepo dependency versions in sync (v15 guide)

> Install syncpack, write a .syncpackrc, fix version mismatches, enforce semver ranges and run it in CI. Updated for syncpack 15.

By [Léo Pradel](https://www.leopradel.com/about) • Published March 10, 2025 • Updated October 26, 2026 • 7 min read

Original: https://www.leopradel.com/blog/consistent-monorepo-dependencies-syncpack

[Syncpack](https://syncpack.dev/) 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 `react@18.2.0`, 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:

```bash
pnpm add -Dw syncpack
# or
npm install --save-dev syncpack
# or
yarn add -D syncpack
# or
bun add -d syncpack
```

## Linting and formatting

Syncpack discovers packages from pnpm, npm and Yarn workspaces. Start with the read-only commands:

```bash
# 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 lint
```

`lint` 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:

```bash
# Preview what would change
pnpm syncpack fix --dry-run

# Write the changes to package.json files
pnpm syncpack fix

# Then update the lockfile
pnpm install
```

Formatting 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:

```jsonc
// .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:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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:

```bash
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 catalog
```

By 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](https://www.leopradel.com/blog/securing-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:

```yaml
# .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 --check
```

You 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.
