Structuring a Turborepo Monorepo for Frontend Teams
A pragmatic Turborepo layout: package boundaries, the task graph, caching that actually hits, and avoiding the mistakes that slow teams down.
A monorepo is not automatically a good idea. It is a tradeoff that pays off when you have multiple apps and shared code that evolve together, and that becomes a tax when you adopt it for its own sake. Turborepo makes the good case much more workable by giving you a fast, cache aware task runner on top of your package manager's workspaces. The value you get from it depends almost entirely on how you structure the repo.
The shape that works
The layout that holds up over time separates deployable applications from shareable packages. Apps consume packages. Packages do not consume apps. That single rule prevents the dependency tangle that makes monorepos miserable.
apps/
web/
docs/
admin/
packages/
ui/
config/
utils/
tsconfig/
Each app is a deployable unit. Each package is a unit of shared code with a clear public interface. The config and tsconfig packages hold shared ESLint, Prettier, and TypeScript base configurations so every app and package extends one source of truth rather than copying configuration around.
The health of a monorepo is mostly determined by the direction of its dependencies. Apps depend on packages, packages depend on packages beneath them, and nothing depends upward. Keep the arrows pointing one way and the repo stays sane.
The task graph is the whole point
Turborepo's core idea is that your build, lint, and test commands form a graph. Building web requires first building the ui package it depends on. Turborepo understands these relationships from your workspace dependencies and runs tasks in the correct order, in parallel where it can.
You declare this in the pipeline configuration. The dependsOn field with the ^ prefix means a task depends on the same task in its dependencies.
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**"]
},
"lint": {},
"test": {
"dependsOn": ["^build"]
}
}
}
The ^build entry tells Turborepo that before building a package, it must build that package's own dependencies. The outputs field tells it what files a successful build produces, which is what makes caching work.
The distinction between ^build and a plain dependency is worth getting precise, because mixing them up produces graphs that are either too strict or subtly wrong. The caret means "this same task in my dependencies." A dependency without the caret, listed by task name, means "this other task in the same package." So a test task that depends on build (no caret) waits for its own package to build first, while ^build waits for the upstream packages to build. Real pipelines combine both: a deploy task might depend on build in its own package and on ^build across its dependencies. Drawing these relationships accurately is what lets Turborepo parallelize aggressively while never running a task before its real inputs exist. A graph that is too loose races and fails intermittently; a graph that is too strict serializes work that could have run in parallel and slows everyone down.
Caching is where the speed comes from
The reason teams adopt Turborepo is the cache. Turborepo hashes the inputs to each task: the source files, the dependencies, the configuration, the environment variables you declare as relevant. If the hash matches a previous run, it replays the cached output instead of doing the work. A build of an unchanged package becomes near instant because it never actually runs.
This only works if your task definitions are honest about their inputs and outputs. If a task reads an environment variable you did not declare, the cache can serve a stale result. If it writes to an output path you did not list, the cache will miss the artifact. Declaring inputs and outputs accurately is the difference between a cache that saves you minutes and one you stop trusting.
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"env": ["NODE_ENV", "NEXT_PUBLIC_SITE_URL"]
}
}
}
Listing env keys means a change to those variables correctly busts the cache, while changes to unrelated variables do not.
Remote caching makes the team faster, not just you
Local caching speeds up your own repeated runs. Remote caching shares those cached artifacts across the team and across continuous integration. When a teammate or a CI job builds a package you already built with the same inputs, it downloads your artifact instead of rebuilding. On a team this is transformative, because the work of building shared packages is done once and reused by everyone whose inputs match.
The place remote caching pays off most dramatically is continuous integration, where the same work would otherwise be repeated on every pull request from a cold machine. With a shared remote cache, a CI run that touches only the web app downloads the prebuilt ui and utils artifacts instead of rebuilding them, so the pipeline does the minimum work the change actually requires. The flip side is that remote caching makes your input declarations matter even more, because a cache key that is wrong now serves a stale artifact not just to you but to the entire team and every CI job. The failure is silent and confusing: a build that should have picked up a change replays an old result, and the bug appears far from its cause. This is why the honesty of your inputs, outputs, and env declarations is the foundation everything else rests on, and why it is worth auditing them whenever a cache result surprises you.
Package boundaries are an architecture decision
The most common monorepo mistake is letting packages become a dumping ground with no clear interface. A utils package that everything imports from and that imports from everything becomes a hub that couples the whole repo together and ruins caching, because a change anywhere in it invalidates everyone.
Treat each package like a small library with a deliberate public surface. Export what consumers need, keep internals internal, and split packages along the lines that actually change together. A ui package, a data package, and a config package change for different reasons and on different cadences, which means a change to one does not needlessly rebuild the others.
There is a concrete caching reason to split along the lines that change together rather than by superficial category. Turborepo invalidates at the package level: touch any file a package counts as an input and that package and everything downstream of it rebuilds. A grab bag utils package that holds date formatting, an API client, and a pile of unrelated helpers becomes a single invalidation unit, so editing the date formatter rebuilds every app that imports anything from utils, even apps that never touch dates. Splitting that into focused packages with narrow responsibilities shrinks each invalidation unit, so a change rebuilds only the consumers that actually depend on the part that changed. Package boundaries are therefore not just an architecture preference, they are the granularity at which your cache works, and a coarse boundary is a coarse cache.
When a monorepo is the wrong call
It is worth saying plainly: if you have one app and no meaningfully shared code, a monorepo adds tooling and conceptual overhead for no benefit. The structure pays off when you have multiple deployables that share real code, when you want atomic changes across app and library in a single commit, and when a unified task graph and cache will save the team time. If those conditions do not hold, a single repo is simpler and you should keep it.
The practical takeaway
A Turborepo earns its keep when the dependency arrows point one way, the task graph reflects real relationships, and inputs and outputs are declared honestly enough that the cache is trustworthy. Get those right and a large repo builds fast and stays comprehensible. Get them wrong and you have all the coupling of a monorepo with none of the speed. The tooling is good, but it amplifies your structure rather than fixing it.
Practical takeaways
- Keep dependency arrows pointing one way: apps depend on packages, packages depend on packages beneath them, and nothing depends upward. This single rule prevents most monorepo pain.
- Model the task graph to reflect real relationships. Use
^buildfor "the same task in my dependencies" and a bare task name for "another task in this package," and combine them where a task needs both. - Declare
inputs,outputs, andenvhonestly. An undeclared environment variable or missing output path is what turns a trustworthy cache into one you stop believing. - Turn on remote caching to share artifacts across the team and CI, where it pays off most, and remember it raises the stakes on accurate cache keys because a wrong one poisons everyone.
- Treat each package as a small library with a deliberate public surface. Split along the lines that change together so a single edit does not rebuild the world.
- Do not adopt a monorepo by default. It earns its keep with multiple deployables sharing real code and atomic cross package changes; one app with no shared code is simpler as a single repo.