I spent a Thursday afternoon last month debugging a bug that didn’t exist.
Our admin dashboard was rendering a field called customerRef as undefined. The API was definitely sending it. I could see it sitting right there in the network tab. The types said the field existed. Everything typechecked. Everything was fine, except obviously it wasn’t.
Turns out the frontend repo had @acme/[email protected] pinned in its lockfile, and the API repo had shipped 2.4.0 about three weeks earlier, where that field quietly got renamed. Nobody bumped the dependency in the dashboard, because nobody really remembered the dashboard was still a separate thing. Two repos, two lockfiles, one contract that had gone stale without making a sound.
That was the afternoon I stopped having opinions about monorepos in the abstract.
Since then I’ve merged four of our repos into one and deliberately left two others exactly where they were. This is my attempt to write down which is which, and why, before I forget and have to relearn it the expensive way.
The version skew is the actual argument, not the tooling
Most monorepo posts open with build caching. I think that’s backwards. Caching is a nice-to-have you can bolt on later. The thing a monorepo genuinely gives you, on day one, for free, is that a shared package cannot be at two versions at the same time.
Here’s what the old setup looked like. Shared types lived in their own repo and got published to our private registry:
// apps/dashboard/package.json (repo #1)
{
"dependencies": {
"@acme/types": "^2.3.0",
"@acme/api-client": "^1.8.2"
}
}
// services/api/package.json (repo #2)
{
"dependencies": {
"@acme/types": "^2.4.0"
}
}
Both of those are “correct”. Both installed cleanly. Both passed CI. And they described two different shapes of the same object.
After the merge, the same thing looks like this:
// apps/dashboard/package.json (one repo)
{
"dependencies": {
"@acme/types": "workspace:*",
"@acme/api-client": "workspace:*"
}
}
workspace:* means “whatever is in this repo right now”. pnpm’s workspace protocol resolves it to the local folder instead of the registry, so there is no version to get wrong. Rename a field in packages/types, and the dashboard build breaks in the same pull request that renamed it. Which is the whole point. The failure moves from production to CI, where failures are cheap.
I want to be honest about how unglamorous this is. It’s not a productivity revolution. It’s just that a category of bug stops being possible.
What it actually costs
The part that doesn’t make it into the setup tutorials is that a monorepo trades a lot of small independent problems for a few large shared ones.
CI gets slower before it gets faster. My first week after merging, every push ran every test in every package, and a two minute pipeline became eleven. That’s fixable, but you do have to fix it, and you have to fix it while people are trying to ship.
Git history gets noisier. git log on the repo root is now four teams’ worth of commits interleaved. I’ve mostly made peace with this by living in git log -- packages/types instead, but I still miss being able to skim a repo’s history and understand what a team was up to that month.
Permissions get blunt. If one contractor needs to touch the marketing site, they now have read access to the billing service too. There are ways around this and none of them are pleasant. This is the single most common reason I’ve seen teams correctly stay on separate repos.
And the tooling stops being optional. In a polyrepo, if your build setup is a bit crusty, that’s one repo’s problem. In a monorepo, a bad task graph is everyone’s problem, every day.
Google wrote up their version of these tradeoffs in Why Google Stores Billions of Lines of Code in a Single Repository, and it’s worth reading even though almost none of it applies to a team of six. What stuck with me is how much custom infrastructure they had to build for the model to hold up. The monorepo didn’t come free for them either. They just decided the bill was worth paying.
pnpm workspaces first, task runner second
My strong recommendation, having done this the hard way: get the workspace working with nothing but pnpm before you install a task runner.
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
- "services/*"
That’s it. That single file plus workspace:* dependencies solves the version skew problem, which was the reason you’re here. You can run scripts with pnpm -r --filter dashboard build and it will be fine for a surprisingly long time.
Add a task runner when your CI time starts to hurt, not before. When I did add one, I went with Turborepo mostly because the config is small enough to hold in your head:
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"test": {
"dependsOn": ["build"]
},
"lint": {}
}
}
The ^build means “build my dependencies first”. The outputs field is what makes caching work, and it’s also where I got it wrong initially: I forgot to list dist/** on one package, so Turbo happily cached a task that produced nothing and I spent an hour convinced the cache was broken. It wasn’t. I was.
Nx does more. Generators, dependency graph visualisation, a plugin for basically every framework. If you’ve got fifteen packages and a team that will actually use code generation, that extra machinery earns its keep. At four packages I found myself fighting it more than using it, so I’d suggest starting with the smaller tool and moving up if you outgrow it. Moving from Turborepo to Nx later is annoying but not hard. Ripping either one out of a codebase that grew around it is much worse.
The rule I use now
I’ve landed somewhere pretty simple, and it isn’t about repo count or team size.
Two codebases belong in the same repo when a change to one routinely requires a change to the other in the same week. Not “could conceivably affect”. Routinely. If your API and your dashboard ship features together, they share a repo. If your marketing site changes when the design team feels like it and never because the billing service changed, keep it separate.
By that rule, the four things I merged were: the API, the dashboard, the shared types package, and the internal admin tool. They moved together constantly. The two I left alone were the marketing site (different deploy cadence, different people, no shared code beyond a logo) and an old Rails service that we’re slowly strangling and don’t want to give a vote in anyone’s CI pipeline.
There’s a related thing worth flagging. If most of your cross-repo pain is specifically about API types drifting out of sync, a monorepo isn’t the only fix. Sharing a type-safe client can get you a lot of the same benefit without restructuring anything, which is roughly the argument I made in my post on tRPC and the type-safe API I actually reach for. Merging repos is a big move. Try the small one first.
What I’d do this week
If you’re staring at three repos and wondering whether to merge them, don’t start with the merge. Start with the measurement.
Go through your last thirty pull requests and count how many needed a matching PR in a different repo to be useful. If it’s under about a fifth of them, you don’t have a monorepo problem, you have two or three specific interfaces that are too chatty, and you should fix those instead. If it’s more than half, the repos are already one codebase and you’re just paying a tax to pretend otherwise.
If you do merge, do it in one commit with git subtree so history survives, put pnpm-workspace.yaml in place, convert the shared packages to workspace:*, and stop there. Ship that. Live with it for two weeks. Add Turborepo only when someone complains about CI time, and only then.
I do this kind of build and infrastructure work on most of the projects in my portfolio, and the pattern holds up: the repos that should be merged are almost always obvious in hindsight and almost always argued about for months beforehand. The PR count tells you faster than the argument will.