TypeScript Monorepos with Turborepo: Lessons from Real Projects

I moved three client projects from standalone repos into a Turborepo monorepo over the past year. Two of them went well. One of them taught me expensive lessons. This post is a condensed version of what I learned — specifically the parts that are easy to miss until they bite you in CI at 2am.
Turborepo itself is excellent. The trap is assuming the defaults will do the right thing for a real project. They will not. You need to opt into correctness.
Why I moved off Nx
Nx is more powerful but also more opinionated. The generators, the plugin system, the project graph — all great when you live inside Nx land, all friction when you need to do anything custom. Turborepo is the opposite: tiny, fast, and stays out of your way. For teams that already have a build setup they like (Vite, tsup, Next.js), Turborepo adds caching and task orchestration without forcing a rewrite. That matched my situation, so I switched.
Three rules I wish I had known
- Define inputs globs explicitly on every task. The default of "hash everything in the package" is both too broad (it busts the cache when you edit a README) and dangerously narrow for tasks that depend on config files at the repo root.
- Turn on remote caching from day one, but gate it behind an environment check. A shared cache with incorrect inputs is worse than no cache — it serves stale builds to every developer on the team.
- Keep shared packages small and focused. A shared-ui package that re-exports everything is a rebuild amplifier: one change invalidates every app that imports it, even apps that do not use the changed component.
Cold vs warm CI
On a recent project with six apps and twelve shared packages, here is what the cache actually delivered once configured correctly:
| Scenario | Cold (no cache) | Warm (remote cache hit) |
|---|---|---|
| Lint all packages | 2m 40s | 8s |
| Typecheck all packages | 4m 10s | 12s |
| Build all apps | 11m 20s | 35s |
| Full pipeline (lint + type + build + test) | 22m | 1m 45s |
That cache hit rate is the whole point. A PR that touches one component now rebuilds in under two minutes instead of blocking developers for twenty. On a team of six people each pushing a few times a day, this pays for itself in the first week.
“A monorepo with a broken cache is just a slower polyrepo. The cache is not an optimization — it is the product.”
What I would do differently
- Set up the inputs globs before writing a single shared package. Fixing them later means auditing every hash boundary in the graph.
- Write an ADR documenting which packages live at the root and which live under apps. The boundary is the hardest thing to change six months in.
- Pin the Turborepo version in package.json and the CI image. Behavior between minor versions has changed in surprising ways for me.
- Keep a single source of truth for tsconfig and eslint at the root, extended by each package. Copy-paste configs are the first thing to drift.
The short version: Turborepo is a great tool if you understand what it is actually caching and why. If you are migrating into a monorepo and want a second set of eyes before you commit to a structure — I have done this three times and am happy to share the pitfall list on a call.
More context on the broader architecture tradeoffs in my microservices vs. monolith post, and if you want to talk through a specific migration, get in touch.
Need help with your project?
Let’s talk about your technical requirements. I offer a free discovery call where we’ll discuss architecture, tech stack, and timeline.
View my services