Monorepo and Turborepo: When You Need It and When You Don't
When a second project appears in a team, the first technical question is always the same: where does the code go? Do we open a separate repository or keep everything in one place? I have answered this question from both sides: with separate repos I got tired of keeping versions in sync, and in a monorepo I once waited hours for CI because of a misconfigured cache. In this article we will look at when a monorepo delivers real value, when it is just extra complexity, and how to work with Turborepo in practice.
What a monorepo is — and what it is not
A monorepo is several independent applications and packages living in a single git repository. Note the word: independent. apps/web and apps/api are built separately, deployed separately, and can have different versions. The only thing they share is where the code lives.
Confusing this with a monolith is a common mistake. A monolith is an architecture decision: the whole system runs and deploys as one application. A monorepo is a code organization decision. You can keep five microservices in a monorepo, and you can write a monolith across a polyrepo — the two axes are independent of each other.
When it pays off
I have been using a monorepo in work projects for three years, and the benefits show up clearly in three places.
- Shared types and utility packages. The frontend and the backend use the same
typespackage. When the API contract changes, TypeScript immediately flags errors on both sides — at compile time, not in production. - Atomic changes. A new endpoint and the UI that consumes it land in a single PR. With two repositories this turns into two PRs, two reviews, and the "merge the backend first, then the frontend" dance.
- One CI and one standard. Lint rules, test configuration, dependency versions — everything lives in one place. The "we are on ESLint 8, they are on 9" problem disappears.
When it is overkill
A monorepo is not free. Setting up the tooling, understanding the cache, guarding package boundaries — all of it costs time. In the following cases that price does not pay off:
- There is only one app. If there is no shared code, there is nothing to share. A plain repository is enough.
- The team is very small. When one or two people work on a single project, the coordination problem that a monorepo solves does not exist yet.
- Package boundaries are unclear. If you do not know which code belongs to which package, a monorepo will not resolve that confusion — it will formalize and multiply it. Understand the domain first, split second.
Turborepo basics
Turborepo is an orchestrator that runs tasks inside a monorepo (build, lint, test) in the right order and from cache whenever possible. All the configuration lives in turbo.json:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"lint": {},
"test": {
"dependsOn": ["build"],
"inputs": ["src/**", "test/**"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}The most important line is "dependsOn": ["^build"]. The ^ symbol means "build my dependencies first": if the web package depends on ui, then turbo build builds ui first and web after. test is tied to build without the ^ — that means "run tests after my own build finishes".
The local cache is Turborepo's core magic. For every task turbo computes a hash: the package's source files (inputs), the hash of its dependencies, the environment variables it uses, and the task configuration. The result is stored under that hash in the .turbo folder. Next time, if the hash has not changed, turbo does not run the build — it simply replays the ready outputs and the terminal logs. When you see FULL TURBO in the terminal, everything came from cache.
Remote cache: the main win for teams
The local cache only works on your machine. The remote cache stores the same hashes on a shared server — on Vercel or self-hosted:
npx turbo login
npx turbo linkNow if a colleague has already built the ui package, running turbo build after git pull pulls that result from the server — you do not rebuild it. In CI the effect is even stronger: a pull request builds only the packages that changed, the rest comes from cache. In our project the average CI time dropped roughly in half — the difference is sharpest in PRs that touch only one small package.
A practical structure
Here is the typical structure I use:
apps/
web/ # Next.js frontend
api/ # NestJS backend
packages/
ui/ # shared React components
config/ # eslint, tsconfig, prettier presets
types/ # API contracts, zod schemasPackages are linked through the workspace: protocol:
{
"name": "@acme/web",
"dependencies": {
"@acme/ui": "workspace:*",
"@acme/types": "workspace:*"
}
}The structure alone is not enough — you need dependency rules. Mine are simple:
apps/*depend on packages, but no package ever depends onapps/*.typesis the bottom layer; it depends on nothing.uimay depend only ontypes.configexports no code, only presets.
If you enforce these rules with ESLint import restrictions, the boundaries live in the code, not in documentation.
Common traps
- Circular dependencies. If the
uipackage imports fromtypesandtypesfor some reason imports fromui, turbo cannot build the dependency graph and the build breaks. The fix is strict one-directional layers: a lower layer never knows about a higher one. - The "everything goes to shared" disease. Every new function immediately moves into a shared package, and eventually any touch to
sharedrebuilds half the repository. My rule: until code is needed in at least two places, it stays where it lives. - Wrong `inputs`. This is the most dangerous trap because it fails silently. If the build depends on code generation or an env file that is not listed in
inputs, turbo returns a stale cache — and you deploy old code. The opposite also happens: ifinputsis too broad, even a README change invalidates the cache. Declare environment variables explicitly in theenvfield and shared files inglobalDependencies.
Conclusion: a decision tree
In short, the logic I use myself:
- One app, no shared code — a plain repository; do not think about a monorepo.
- Two or more apps use shared types and components — monorepo + Turborepo; this is the strongest case.
- Many apps, but almost no shared code between them and the teams work independently — polyrepo is also a fine choice.
- If you chose a monorepo — write down the package boundaries on day one and guard them with a linter.
A monorepo is a tool, not a goal. It solves a coordination problem; if the problem does not exist yet, the solution is not required either.