DEV Community

Cover image for Governing npm Dependencies Across a Monorepo
Matti Bar-Zeev
Matti Bar-Zeev

Posted on

Governing npm Dependencies Across a Monorepo

If you've been following my Pedalboard monorepo journey, from setting it up from scratch, to migrating from Yarn to pnpm, to bringing in Bazel for builds. You know that a monorepo is a living thing. You add packages, tweak configs, and things evolve. That's the whole point.

But here's the thing that sneaks up on you: dependency drift. You start with good intentions. Every package pins the same version of typescript. A few months later, one package got bumped when you fixed a bug, another one didn't because "it wasn't broken." Now hooks runs TypeScript 5 while components is still chugging along on TypeScript 4. Both are "working," so nobody notices. Until they do.

I noticed. Looking at Pedalboard right now, that's exactly the situation I'm in. @pedalboard/hooks declares "typescript": "^5.3.3" while @pedalboard/components still has "typescript": "^4.6.4". Harmless? Maybe. The kind of thing that compounds over time and becomes a headache? Definitely.

So in this post, I want to walk through how to actually govern dependency versions in a pnpm monorepo. Not just "don't let them drift" as a philosophy, but the concrete tooling and enforcement that makes it a property of the repo rather than a habit that relies on people remembering things.

What I'm Actually Trying to Achieve

Before reaching for any tool, I think it's worth being explicit about the goals. "Govern dependencies" is vague enough to mean a dozen different things. Here's what I actually want out of this:

Both prod and dev dependencies. It's tempting to only care about production deps. Those are the ones that ship to users, right? But dev dependency drift is just as real and just as annoying. Having vitest@3 in one package and vitest@2 in another creates subtle test environment differences that are genuinely hard to debug. So everything is in scope here.

Each package stays standalone. This one is important to me. I want each package under packages/* to explicitly declare every dependency it needs in its own package.json. No leaning on something that happens to be installed at the root. If @pedalboard/hooks needs typescript, it should say so. The root should not be a silent dependency provider that packages rely on without realizing it.

The version, however, should come from a single source of truth at the root level. So the pattern I'm after is: packages own the declaration, the root owns the version. That way bumping typescript from 5.3 to 5.5 is a one-line change in one place, and every package picks it up automatically.

Enforcement that doesn't rely on good intentions. I want this to be mechanical. Two levels ideally: a pre-commit hook that catches the obvious mistakes locally before they ever hit the remote, and a CI check that acts as the final gate. The pre-commit hook is a convenience. The CI check is the law.

Those are the constraints. Now let's figure out how to build a system that satisfies all three.

First, Let's See Where Things Actually Stand

Before fixing anything, it helps to know exactly what's broken. There's a great tool for this called syncpack. It scans all your package.json files and reports version inconsistencies across the monorepo.

I installed it as a root dev dependency:

pnpm add -D syncpack -w
Enter fullscreen mode Exit fullscreen mode

Then I wired up a script in the root package.json so this is easy to run going forward:

"deps:lint": "syncpack lint"
Enter fullscreen mode Exit fullscreen mode

Now let's run it and see what we're dealing with.

The Damage Report

Here's what pnpm deps:lint spat out:

= Default Version Group ========================================================
   2x chromatic
      ✘ 10.6.1 → ^11.0.8 in packages/components/package.json at .devDependencies
   2x stylelint
      ✘ ^14.16.0 → 15.11.0 in packages/components/package.json at .devDependencies
   6x typescript
      ✘ ^4.6.4 → ^5.3.3 in packages/components/package.json at .devDependencies
      ✘ ^4.6.4 → ^5.3.3 in packages/eslint-plugin-craftsmanlint/package.json at .devDependencies
      ✘ ^4.6.4 → ^5.3.3 in packages/git-hooks/package.json at .devDependencies
      ✘ ^4.6.4 → ^5.3.3 in packages/stylelint-plugin-craftsmanlint/package.json at .devDependencies
✗ Issues found
Enter fullscreen mode Exit fullscreen mode

Three offenders. And honestly, not surprising. This is exactly the drift I described in the intro, just made visible.

typescript is the most widespread: four packages are still on ^4.6.4 while the other two have moved to ^5.3.3. Syncpack flags the lower versions and points you at the highest one found across the workspace as the target. It's not making a judgment call about which version is correct. It's just telling you they disagree, and here's where.

chromatic and stylelint are each mismatched in one package. chromatic has an exact version pinned in one place (10.6.1) and a range in another (^11.0.8). stylelint has a range in one place (^14.16.0) and an exact pin in another (15.11.0). Both of these are the kind of thing that happens when one package gets updated and the PR author didn't think to check the others.

Exit code 1 means this is CI-friendly out of the box. Just run it and let the exit code do the work.

Now let's actually fix them.

Fixing TypeScript With pnpm Catalogs

I could have just run syncpack fix and let it bump the four straggler packages up to ^5.3.3. That would clear the lint output. But it wouldn't actually solve the problem. It would just reset the clock. The next person to update typescript in one package and forget the others would put us right back here.

What I want is the pattern I described in the goals: packages own the declaration, the root owns the version. pnpm has a feature for exactly this, called catalogs. You define a version once in pnpm-workspace.yaml, and packages reference it with the catalog: specifier instead of a version string. When pnpm resolves dependencies, it substitutes the real version in. When you publish a package, pnpm replaces catalog: with the actual resolved version so consumers get a normal semver string.

Setting up the catalog

First, I added a catalog: key to pnpm-workspace.yaml:

packages:
  - 'packages/*'

catalog:
  typescript: ^5.3.3
Enter fullscreen mode Exit fullscreen mode

That's the single source of truth. One line. If I ever want to move to TypeScript 5.5, this is the only place I touch.

Then I replaced every "typescript": "<version>" across all six packages with "typescript": "catalog:". Before:

"devDependencies": {
    "typescript": "^4.6.4"
}
Enter fullscreen mode Exit fullscreen mode

After:

"devDependencies": {
    "typescript": "catalog:"
}
Enter fullscreen mode Exit fullscreen mode

Same change in all six package.json files: components, hooks, media-loader, git-hooks, eslint-plugin-craftsmanlint, and stylelint-plugin-craftsmanlint. Then pnpm install to let pnpm resolve the catalog reference and update the lockfile.

Did it work?

= Default Version Group ========================================================
   2x chromatic
      ✘ 10.6.1 → ^11.0.8 in packages/components/package.json at .devDependencies
   2x stylelint
      ✘ ^14.16.0 → 15.11.0 in packages/components/package.json at .devDependencies
✗ Issues found
Enter fullscreen mode Exit fullscreen mode

typescript is gone from the list. Two mismatches left.

I did the exact same for chromatic and stylelint, added them to the catalog in pnpm-workspace.yaml and replaced their version strings with "catalog:" in the relevant packages. After a fresh pnpm install, pnpm deps:lint came back clean:

✓ No issues found
Enter fullscreen mode Exit fullscreen mode

The catalog now looks like this:

catalog:
  chromatic: ^11.0.8
  stylelint: 15.11.0
  typescript: ^5.3.3
Enter fullscreen mode Exit fullscreen mode

One small thing I noticed: stylelint appears as a prod dependency in stylelint-plugin-craftsmanlint (because the plugin needs it at runtime) but as a devDependency in components. The catalog entry covers both. It doesn't care which section of package.json uses it, which is exactly the right behavior.

Going All In: Cataloging Everything

With three deps cataloged and the pattern proven, the next logical step was obvious: do this for every single dependency in the repo, not just the ones that happened to be mismatched.

The point isn't just to fix the current drift. It's to make future drift structurally impossible. If every dep in every package uses "catalog:", there's nothing to drift. The version lives in exactly one place and that's it.

I collected all unique deps from dependencies and devDependencies across all eight package.json files, generate the full catalog, and replace every version string with "catalog:" in one shot. Running it produced:

Updated pnpm-workspace.yaml with 66 entries
package.json: 20 deps updated
packages/components/package.json: 36 deps updated
packages/hooks/package.json: 8 deps updated
packages/media-loader/package.json: 39 deps updated
packages/git-hooks/package.json: 4 deps updated
packages/eslint-plugin-craftsmanlint/package.json: 3 deps updated
packages/stylelint-plugin-craftsmanlint/package.json: 7 deps updated
packages/scripts/package.json: 8 deps updated
Enter fullscreen mode Exit fullscreen mode

125 replacements across 8 files, in one go.

The YAML gotcha

The first pnpm install after that failed with a YAML parse error:

bad indentation of a mapping entry (5:3)

 4 | catalog:
 5 |   @babel/core: ^7.16.5
-------^
Enter fullscreen mode Exit fullscreen mode

The peer deps question

After the install succeeded, syncpack came back with a new complaint:

   4x react
      ✘ ^18.3.1 → catalog: in packages/components/package.json at .peerDependencies
      ✘ ^18.3.1 → catalog: in packages/hooks/package.json at .peerDependencies
      ✘ ^18.3.1 → catalog: in packages/media-loader/package.json at .peerDependencies
Enter fullscreen mode Exit fullscreen mode

Since react is now in the catalog (the root workspace has it as a regular dependency), syncpack caught that the peerDependencies entries in the three component packages still carry a raw version string.

This is actually a deliberate choice. peerDependencies are a different kind of declaration. They're a compatibility statement to the outside world, saying "I work with this range of react." They aren't installed by pnpm here, they're fulfilled by the consumer's app. Managing them through a catalog would mean all packages have to claim identical compatibility ranges, which isn't necessarily true as packages evolve at different rates.

So the right call is to keep peerDependencies as explicit version strings and just tell syncpack not to look at them:

"deps:lint": "syncpack lint --dependency-types '!peer'"
Enter fullscreen mode Exit fullscreen mode

The !peer flag excludes peer dependencies from the check. But putting CLI flags in npm scripts feels fragile. The intent belongs in config, not in the command. So I moved it into .syncpackrc.json instead, using a proper versionGroups entry:

{
    "$schema": "./node_modules/syncpack/schema.json",
    "versionGroups": [
        {
            "dependencyTypes": ["peer"],
            "isIgnored": true
        }
    ]
}
Enter fullscreen mode Exit fullscreen mode

This makes the deps:lint script a plain syncpack lint again, and the peer dep exclusion is documented in config where it's visible and intentional.

After that:

✓ No issues found
Enter fullscreen mode Exit fullscreen mode

The full catalog in pnpm-workspace.yaml is now the single source of truth for all 69 dependencies across the monorepo. Every package.json either uses "catalog:" for versioned deps, or "workspace:^" for internal cross-package references. Nothing else.

Grouping Related Deps With dependencyGroups

With everything cataloged and the lint passing, I wanted to take it one step further. Syncpack v14 has a dependencyGroups config option that lets you treat a family of packages as a single logical dependency for version consistency checking. If @storybook/react and @storybook/addons are both in the catalog but somehow end up at different versions, syncpack should catch that they drifted, not just report two separate unrelated entries.

I added four groups to .syncpackrc.json, covering the families that genuinely need to move in lockstep:

"dependencyGroups": [
    {
        "dependencies": ["react", "react-dom"],
        "aliasName": "react-family"
    },
    {
        "dependencies": ["@storybook/**", "storybook"],
        "aliasName": "storybook-family"
    },
    {
        "dependencies": ["@typescript-eslint/**", "typescript-eslint"],
        "aliasName": "typescript-eslint-family"
    },
    {
        "dependencies": ["@vitest/**", "vitest"],
        "aliasName": "vitest-family"
    }
]
Enter fullscreen mode Exit fullscreen mode

The grouping criteria was simple: does it make sense for all packages in this group to always be on the same version? react and react-dom: yes. All @typescript-eslint/* packages: yes. But @babel/core and @babel/runtime? They're in the same namespace but intentionally at different versions. So babel stays ungrouped.

I left @types/** ungrouped too. Those are each tied to a different package and obviously differ by design.

Running pnpm deps:lint after adding the groups surfaced one unexpected issue: a stale pnpm.overrides entry in the root package.json that was forcing @storybook/node-logger to 7.6.21. The storybook group caught it as a version inconsistency. Turned out that override was a leftover from the Yarn-to-pnpm migration, no longer needed, removed it, and the lint came back clean.

Locking It In With CI

At this point, pnpm deps:lint passes locally and the catalog is the single source of truth. But "it works on my machine" isn't governance. It's just a habit. I said at the start that the CI check is the law, so let's make it so.

The Pedalboard CI lives in .github/workflows/npm-publish.yml. It already runs install, lint, test, and build on every push to master and on every release. Adding deps:lint is a one-liner:

- run: pnpm install
- run: pnpm run deps:lint
- run: pnpm run lint
- run: pnpm run test
- run: pnpm run build
Enter fullscreen mode Exit fullscreen mode

I put it right after install and before lint. It's a structural check, not a code quality check, so it should fail fast before we even get to running ESLint or tests. If someone uses a raw version string that differs from what's defined in the catalog, syncpack catches it and the build fails right there.

The exit code 1 behavior syncpack has by default is what makes this seamless. No extra configuration needed. The npm script already exits with 1 on any issue, and GitHub Actions treats that as a failed step. That's it. The check that was a local convenience is now the automated gate.

The Pre-commit Hook: Catching It Before It Leaves Your Machine

CI is the law, but it's also slow feedback. You push, wait for the pipeline, get a failure email, go back and fix it. A pre-commit hook is the same check running in two seconds on your laptop before the commit even exists. I called it a convenience in the goals section. That's the right framing. It doesn't replace CI, it just moves the feedback loop earlier.

Pedalboard already has a git hooks setup. The .git-hooks/ directory holds the hooks, and a postinstall script points git at it:

"setup:git-hooks": "git config core.hooksPath .git-hooks",
"postinstall": "pnpm run setup:git-hooks"
Enter fullscreen mode Exit fullscreen mode

So anyone who runs pnpm install automatically gets the hooks wired up. There's already a commit-msg hook in there, a Node.js script that validates conventional commit format using the @pedalboard/git-hooks package. Adding a pre-commit hook is just creating a new file in the same directory.

For this one, a plain shell script is all it needs to be:

#!/bin/sh

pnpm run deps:lint
Enter fullscreen mode Exit fullscreen mode

That's the whole file. No Node, no package imports. Just the same pnpm deps:lint command we've been running manually, now running automatically before every commit. If it exits 1, the commit is blocked.

The distinction between the two hooks is worth understanding. pre-commit runs before git even prompts you for a message. It's a gate on the whole commit action. commit-msg runs after you've written the message, with the message file passed as an argument, and validates its content. They're independent, fire at different stages and fail for different reasons.

Wrapping Up

Let me go back to the three goals I set at the start and see where we landed.

Both prod and dev dependencies. Covered. The catalog in pnpm-workspace.yaml holds all 69 dependencies: prod, dev, everything except peer deps. One file to rule them all.

Each package stays standalone. Still true. Every package explicitly declares what it needs in its own package.json. The catalog: specifier doesn't change that. Packages still own the declaration. The root just owns the version.

Enforcement that doesn't rely on good intentions. Done. A pre-commit hook runs pnpm deps:lint before every commit. The CI pipeline runs it before lint and tests on every push. If something drifts, it fails fast at both layers.

What I didn't expect going in was how much housekeeping this process would surface. The dependencyGroups check caught a stale pnpm.overrides entry left over from the Yarn migration. The TypeScript upgrade exposed a peer dep warning in typescript-coverage-report. That's not noise. That's the system doing its job. Drift hides problems. Making everything visible also makes these small inconsistencies visible, and that's a good thing.

The pattern I ended up with is clean: packages declare their deps, the catalog owns the versions, syncpack enforces consistency, and CI makes it a hard gate. Each piece is doing exactly one thing.

If you're running a pnpm monorepo and dependency drift is starting to feel like a background anxiety, this is a pretty low-cost setup to put in place. The catalog migration is the most work, and even that can be scripted in an afternoon.

You can see the full implementation in the Pedalboard repository. If you've tackled this differently in your own monorepo, I'd genuinely like to hear how. Drop it in the comments.

Top comments (0)