Last year I was running four Git repos for what was, honestly, one product: a web app, a marketing site, a shared UI library, and a tiny SDK we handed to a couple of partners. Every Friday I’d do the same dance. Bump the version of the UI library, publish it to our private registry, then walk into the app repo and the site repo, update the dependency in each, hope nothing drifted, and open three pull requests to ship one button change.
I told myself this was clean architecture. It wasn’t. It was four copies of the same ESLint config, three CI pipelines that quietly disagreed about which Node version to use, and a [email protected] that the marketing site was somehow still pinned to in March while everything else had moved on. One weekend I merged the whole thing into a single repo, and most of that busywork just evaporated.
So this is the reasoning I wish I’d had before I started. Not a sales pitch for one side. By the end you should know which setup fits the thing you’re actually building.
The Friday dance: what polyrepo actually cost me
“Polyrepo” just means one repository per project. It’s the default most of us land on without thinking, because it’s what you get when you run git init a few times. For genuinely independent services it’s fine. My problem was that my repos weren’t independent. They shipped together and broke together, but Git didn’t know that.
Here’s the part that wore me down. Any change to the shared library turned into a publish-and-chase loop:
# In the shared-ui repo
npm version patch # 1.4.2 -> 1.4.3
npm publish
# Then, over in the app repo
npm install @acme/[email protected]
# ...and again in the marketing-site repo
npm install @acme/[email protected]
Three steps to move one button two pixels. Worse, there was no way to make an atomic change. If I renamed a prop in the library, the library PR and the two consumer PRs lived in different histories. A teammate could merge one and not the others, and now main was green in three repos that didn’t actually work together. Google wrote up this exact failure mode at a much larger scale in their piece on why they keep billions of lines in one repository, and the core argument held for my tiny four-repo version of it: when code changes together, it should live together.
What a monorepo is, minus the hype
A monorepo is one repository that holds several packages or apps, with shared tooling at the root. That’s the whole idea. It is not “one giant app,” and it is not a Google-only thing you need a platform team to run. A solo developer with two packages already has a reasonable case for one.
The shift that matters: instead of publishing the shared library and reinstalling it everywhere, the consumers reference it directly inside the same repo. A change to the library and the code that uses it land in a single commit, reviewed together, merged together. If you want to see the various tools that compete in this space, monorepo.tools keeps a fair comparison that isn’t trying to sell you anything.
The setup I actually reach for: pnpm workspaces + Turborepo
For JavaScript and TypeScript projects I use two things: pnpm workspaces to link the packages, and Turborepo to run tasks across them without rebuilding the world every time.
The linking is one file. You tell pnpm where your packages live:
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
Then a consumer depends on the shared package using the workspace protocol instead of a version number:
// apps/web/package.json
{
"dependencies": {
"@acme/ui": "workspace:*"
}
}
That workspace:* is the whole trick. There’s no publish step and no version to chase. When I edit packages/ui, the app sees it on the next reload because pnpm symlinks the package into node_modules. The Friday dance from earlier collapses into one commit. If you haven’t moved off npm yet, I wrote up why pnpm’s disk model alone was worth the switch in pnpm vs npm, and workspaces are the bigger reason I stayed. The official pnpm workspaces docs cover the edge cases I’m glossing over.
The second piece is task running. Once you have eight packages, npm run build everywhere gets slow and dumb. Turborepo lets you describe how tasks relate and then caches the results:
// turbo.json
{
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
"lint": {},
"test": { "dependsOn": ["build"] }
}
}
The ^build means “build my dependencies first.” The caching means a package that didn’t change doesn’t get rebuilt or retested. On my repo that took CI from around six minutes to under two on most pushes, because most pushes only touch one package. The Turborepo docs explain remote caching too, which is where the bigger wins show up once you have a team. Nx is the other serious option here, and if your repo leans heavily on code generation and project graphs, Nx is worth a look before you commit.
When I still reach for polyrepo
I’m not here to tell you everything belongs in one repo. A few times in the last year I deliberately kept things separate, and I’d do it again.
If two projects ship on genuinely different cadences and never need an atomic change across them, the monorepo just adds coordination overhead for no payoff. A public open-source library I publish for strangers stays in its own repo, because its release cycle has nothing to do with my app and outsiders shouldn’t have to clone my marketing site to fix a typo. Hard security or access boundaries are another one. If a contractor should see one service and nothing else, repo-level permissions are a blunt but honest tool, and splitting a monorepo’s access by folder is fiddly.
And monorepos have real costs I underplayed when I was excited about mine. CI gets more involved, because now you care about which packages a commit affects rather than just running everything. You take on a task runner as a dependency, and that’s a thing that can break. Git history gets noisier when every change to anything lands in the same log. None of these sank me, but pretending they don’t exist is how people end up resenting a setup they chose in a hurry.
How I actually decide
My rule of thumb fits in a sentence: do these things need to change together? If a single logical feature regularly touches more than one of your projects in the same pull request, that’s the signal to merge them. If your projects mostly evolve on their own and only occasionally share a utility, the publish-a-package model is fine and the monorepo is overkill.
A second check is team boundaries. One team, or a small group that trusts each other, gets almost pure upside from a monorepo. Many teams with conflicting tooling opinions and separate on-call rotations will fight over the shared root, and that political cost is real even when the technical case is clean. I tend to weigh the human side as much as the build times when I make this call for clients, which is the kind of plumbing decision I spend a lot of my consulting work on.
What to try this week
Pick the two repos that always seem to ship in lockstep, the ones where you keep opening paired pull requests. Make a new folder, drop both projects under apps/, add a four-line pnpm-workspace.yaml, and switch one cross-dependency to workspace:*. Run the build once. You’ll feel within about thirty minutes whether the atomic-commit model fits how your code actually changes. If it doesn’t, you’ve lost half an hour and learned something. If it does, you’ve just deleted your own Friday dance.