Turborepo: Monorepos for Multi-App Shared Libraries
A monorepo is not “a very large Git repository” as a flex—it is a coordination technology. When two or more applications genuinely share UI, data clients, types, or build tooling, shipping them from separate repos without automation turns every shared change into a multi-step release theater: bump a package, wait for publish, upgrade consumers, chase version skew, repeat. Monorepos trade some repository size and tooling complexity for atomic commits, a single CI graph, and the ability to refactor across boundaries the way you refactor inside one app.
Turborepo sits in a crowded space next to Nx, Bazel-style tooling, and ad hoc npm workspaces. For
many React and Next.js teams in 2026, Turborepo wins on simplicity of configuration, first-class
integration with Vercel’s remote cache, and a task model that maps cleanly onto package.json
scripts. It does not try to be a full enterprise build system; it tries to run the right scripts in
the right order, as fast as possible, with caching that actually works.
When a monorepo is the right move
Reach for a monorepo when at least one of these is true:
- Two or more deployable apps share a design system, hooks, or typed API clients.
- Multiple teams work on related surfaces (marketing site, logged-in dashboard, mobile shell) and need consistent TypeScript settings.
- You want atomic changes across packages—rename a prop in
@repo/uiand fix every consumer in one commit.
Skip it when you have one app and no realistic second consumer. Publishing a private npm package
from a single repo is often less machinery than bootstrapping workspaces, turbo.json, and shared
config packages for hypothetical reuse.
Scaffolding in minutes
The official starter wires sensible defaults for pnpm workspaces and Next.js applications:
npx create-turbo@latest
Choose pnpm and the app templates your organization prefers (often Next.js on the web side plus an optional Expo app). The generator gives you a working pipeline so you can focus on package boundaries instead of fighting path aliases for a week.
Repository shape
A typical Turborepo layout looks like:
monorepo/
├── apps/
│ ├── web/
│ ├── dashboard/
│ └── mobile/
├── packages/
│ ├── ui/
│ ├── database/
│ ├── utils/
│ └── typescript-config/
├── turbo.json
└── pnpm-workspace.yaml
apps/* are deployable units. packages/* are libraries consumed by those apps. Keeping that
distinction explicit prevents “accidental apps” that import server-only code into client bundles.
Shared TypeScript and ESLint configs live in packages so extends stays one line and
compiler upgrades happen once.
Shared UI package: exports and peer dependencies
The most common footgun in shared React packages is double React—two copies on the page, broken hooks, and impossible context. Mark React as a peer dependency so the application’s React instance is authoritative:
{
"name": "@repo/ui",
"exports": {
"./button": {
"import": "./src/button.tsx",
"types": "./src/button.tsx"
}
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*"
}
}
Subpath exports keep public API small and tree-shaking honest. Consumers write
import { Button } from '@repo/ui/button' instead of reaching into deep paths that you cannot
rename later.
Shared TypeScript baseline
Centralize strictness. A packages/typescript-config/base.json might look like:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"jsx": "react-jsx"
}
}
Apps and libraries extend this file with their own include paths and any framework-specific
options. The win is not the exact flags—it is that every package agrees on what “strict” means,
so a type error in packages/utils is not silently looser than the dashboard.
Task graph: dependsOn encodes reality
turbo.json describes how tasks compose. The ^ prefix means “depend on the same task in
dependencies first,” which is how builds respect package order:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**"]
},
"dev": {
"persistent": true,
"cache": false
},
"lint": {
"dependsOn": ["^build"]
},
"type-check": {
"dependsOn": ["^build"]
},
"test": {
"outputs": ["coverage/**"]
}
}
}
outputs tell Turborepo what to cache; omit them and you still get orchestration, but you leave
performance on the table. Mark dev as persistent so Turbo knows the process stays alive. For
lint and type-check, depending on ^build is a pragmatic choice when generated artifacts (GraphQL
code gen, protobuf stubs) must exist first—if your repo has no codegen, you might drop that
dependency to keep feedback loops faster.
Remote caching and CI
Local caching already deduplicates work across branches. Remote caching shares those artifacts across machines—CI runners, teammates, preview deploy pipelines. After linking:
npx turbo login
npx turbo link
subsequent runs can skip work that another environment already proved correct. Marketing numbers like “80%+ faster CI” are situational; what is reliable is the model: hashes of inputs determine cache keys, and unchanged dependency graphs stop rebuilding the world. The operational caveat is secret hygiene: ensure caches cannot leak private environment into artifacts you would not publish.
Turborepo versus Nx
Turborepo tends to fit teams that want minimal config, strong Next.js ergonomics, and Vercel-aligned caching. Nx brings a deeper plugin ecosystem, generators, and graph visualization that larger polyglot orgs exploit heavily. Neither is “wrong.” A useful 2026 heuristic: start with Turborepo when your world is mostly TypeScript, React, and Next.js; evaluate Nx when you need first-class orchestration across many languages or heavy codegen-driven workflows.
Opinionated pitfalls to avoid
Do not create twenty micro-packages on day one. Let seams emerge from real reuse. Do not share
“everything utils” without boundaries—packages/utils becomes the new src/services dumping
ground. Prefer a few well-named packages (ui, api-client, config) over a forest of
one-function packages that explode install and mental overhead.
Monorepos fix coordination; they do not fix ownership. If two teams need independent release cadences and conflicting roadmaps, a monorepo can still work—with discipline—but it is not a substitute for product alignment. When organizational independence is the real constraint, runtime integration (the next section’s topic) enters the picture.
Used with intent, Turborepo makes shared React libraries feel like internal open source: explicit exports, typed contracts, and a task graph that mirrors your dependency graph instead of fighting it.