Monorepos
31. October, 2024 • 7 min read • Develop
One repository, several projects
A monorepo is one repository holding several related projects. That is the whole idea. Everything else (the tooling, the caching, the arguments on the internet) is about making that arrangement survive contact with a real codebase.
The version of this problem I know best comes from django CMS, which lives across a lot of separate repositories. Core in one, the editor in another, half a dozen packages beside them. A change that touches more than one of those means a pull request in each, a release in each, and a version bump in the right order. That coordination cost is exactly what a monorepo removes, and it is why the pattern keeps coming back.
It also introduces problems of its own, which is the less fun half of the post.
The shape of one
Take a project with two applications and a shared library:
my-monorepo/
├── apps/
│ ├── web/ # the main application
│ ├── docs/ # documentation site
│ └── api/ # serves the API
├── packages/
│ └── ui/ # shared UI components
├── package.json # workspaces live here
└── turbo.json # task configurationapps/ holds things you deploy, packages/ holds things you import. That split is a convention rather than a rule, but every tool in this space assumes it, so go along with it.
What you get
Changes land in one commit. Update a shared component and the applications that consume it get the new version immediately, in the same pull request, reviewed together. No publish step, no version bump, no waiting for the registry.
Dependencies stop drifting. One lockfile at the root means one version of React across everything. Version mismatches between projects simply stop being a category of bug you have to think about.
Builds get cached. This is the part that actually changes how the day feels. If a package has not changed, its build output is reused rather than recomputed. On a cold clone that saves nothing; on the tenth build of the afternoon it saves most of your time.
Refactoring across boundaries becomes possible. Renaming an exported function and fixing every call site in the same change is only realistic when the call sites are in front of you.
What it costs
Pull requests get noisy. With everything in one place, the repository is busier, review ownership blurs, and the person reviewing your change may not know the package you touched. Codeowners files help. They do not fix it.
Clones get slow. The repository only grows, and git history is the thing that grows fastest. git sparse-checkout and shallow clones take the edge off, but a five-year-old monorepo is not a cheap thing to clone on hotel wifi.
Blast radius goes up. A shared package is shared. Change it carelessly and you have broken three applications rather than one, and you find out in CI rather than in a dependency bump you could have deferred.
Without caching, it is worse than what you had. Running every test in the repository on every commit is the failure mode. If you adopt a monorepo and skip the task graph, you have taken all of the costs and none of the benefits.
Monorepo or single repos
| Aspect | Monorepo | Single repos |
|---|---|---|
| Code reuse | Import directly from a workspace package | Publish to a registry, or copy and paste and regret it |
| Dependency management | One lockfile, one version of everything | Independent, and drifts apart quietly |
| Build efficiency | Fast with a task graph and cache, slow without one | Fast per repo, no way to share work across them |
| Release cadence | Everything moves together, which is good until it is not | Each project releases on its own schedule |
| Onboarding | One clone, one install, one command to run everything | Find the repos, clone them all, hope the readme is current |
The honest summary is that single repos are the right default for projects that do not share code, and a monorepo starts paying the moment two of them do.
Setting up Turborepo
Turborepo handles the task graph and the caching, and leaves package management to your package manager’s workspaces. It does less than the alternatives, which I like.
Scaffold one and run it:
npx create-turbo@latest
cd my-turborepo
npm run devThe generated repository gives you two applications and a shared package, which is enough to see the caching work. Build twice and watch the second run report FULL TURBO.
The tasks config
At the root you get a turbo.json:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"lint": {},
"test": {
"dependsOn": ["build"]
}
}
}Note the key is tasks. It was called pipeline until Turborepo 2.0 landed in June 2024, and a lot of tutorials still have not caught up. If you inherit an older repository, npx @turbo/codemod migrate renames it for you.
Two things earn their keep here. dependsOn: ["^build"] with the caret means “build my dependencies first”, so Turborepo works out the order rather than you maintaining it by hand. And outputs tells the cache what to save; get that list wrong and your cache hits will restore nothing useful, which is a confusing way to spend an afternoon.
Sharing code
Give the shared package a name in its package.json:
{
"name": "@my-monorepo/ui",
"main": "./index.js"
}Export a component from it:
// packages/ui/Button.js
import React from 'react';
export const Button = ({ children }) => <button>{children}</button>;And import it in an application as if it came from npm:
import { Button } from '@my-monorepo/ui';
const HomePage = () => <Button>Click me!</Button>;No build step for the package, no publish, no version. The workspace resolution does the work.
The alternatives
- Nx does considerably more: a dependency graph you can visualise, generators for new packages, and framework integrations for React, Angular and NestJS. If you want the tool to have opinions about your project layout, this is the one.
- Lerna is the old guard and still the best answer if your actual problem is publishing versioned packages to npm. It runs on Nx internally now, which tells you something about how that story ended.
- Bazel comes from Google and handles codebases in many languages at once. The caching is excellent and the learning curve is genuinely steep. Reach for it when JavaScript is not the only thing in the repository.
- Rush comes from Microsoft, targets large TypeScript repositories, and takes dependency policy seriously in a way the others do not.
For a JavaScript or TypeScript repository under a few dozen packages, Turborepo. Beyond that, or with more than one language involved, look at Nx and Bazel in that order.
Things worth doing early
- Enable remote caching before you need it. A cache that only lives on your laptop helps you. A shared one helps CI, which is where the build minutes actually go.
- Get
outputsright in every task. It is the most common configuration mistake and the least obvious one, because nothing fails, it just stays slow. - Write conventions down. Conventional commits help more in a monorepo than anywhere else, because a scope in the commit message tells you which package a change touched without opening it.
- Automate dependency updates. Renovate or Dependabot grouped across the workspace, so one pull request covers the repository instead of twenty.
- Assign ownership per package. Not to gatekeep, but so that reviews land with someone who knows the code.
Where I would draw the line
If you have one application, keep one repository. A monorepo is a solution to a coordination problem, and if you do not have the problem you are just adding a build tool.
The moment there are two deployables sharing real code, move. Waiting until it hurts means migrating a repository with history, CI, and everyone’s muscle memory attached to it, and that is a much worse afternoon than setting it up on day one.
‘Till next time!