When a project grows from a single app into multiple frontends and backends, code organization becomes a real challenge. We adopted Turborepo to manage our multi-app Monorepo, and here's what we learned along the way.
Why Monorepo
In a traditional multi-repo (Polyrepo) setup, sharing code means publishing npm packages. Keeping versions in sync across repos is painful. If the backend defines an order type and the frontend needs it, you'd have to: publish a package → bump the version → install in each repo → hope nothing was missed.
A Monorepo puts everything in one repository, making shared code a direct import:
monorepo/
├── apps/
│ ├── api/ # Backend service
│ ├── admin/ # Admin dashboard
│ └── mobile/ # Mobile app
├── packages/
│ ├── shared/ # Shared types & constants
│ └── api-client/ # Type-safe HTTP client
├── turbo.json
└── package.json
Change a type definition once, and every consumer picks it up immediately—no publishing workflow needed.
What Turborepo Brings to the Table
Turborepo's core value is build efficiency. In a Monorepo, you don't want a one-line backend change to trigger a full frontend rebuild.
Task Orchestration and Caching
turbo.json defines task dependencies:
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {},
"test": {
"dependsOn": ["build"]
}
}
}
"dependsOn": ["^build"] means "build my dependencies first." Turbo analyzes the dependency graph and runs independent tasks in parallel.
The real win is caching—if input files haven't changed, Turbo skips the build entirely and restores outputs from cache. In our project, this cut CI build times from around 8 minutes to about 2.
Filtered Builds
During day-to-day development, you typically only care about one app:
# Build only the admin app and its dependencies
turbo build --filter=admin
# Run the API dev server only
turbo dev --filter=api
--filter is the flag you'll use most often, avoiding full rebuilds every time.
Workspace Dependency Management
With pnpm's workspace:* protocol, internal dependencies are straightforward:
{
"dependencies": {
"@myapp/shared": "workspace:*",
"@myapp/api-client": "workspace:*"
}
}
One common pitfall: shared packages must have proper exports configuration. If packages/shared doesn't declare its entry point in package.json, consumers will get "module not found" errors:
{
"name": "@myapp/shared",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
}
}
Type-Safe API Clients
One of the biggest dividends of a Monorepo is cross-app type safety. We place API request/response types in packages/shared and wrap them with a unified HTTP client:
// packages/api-client/src/index.ts
import type { OrderResponse, CreateOrderRequest } from '@myapp/shared';
export async function createOrder(data: CreateOrderRequest): Promise<OrderResponse> {
const res = await fetch('/api/orders', {
method: 'POST',
body: JSON.stringify(data),
});
return res.json();
}
When the backend changes a field, TypeScript catches it at compile time on the frontend—not at runtime.
Practical Tips
1. Don't over-extract packages. If a piece of code has only one consumer, keep it there. Package management has overhead; only extract when code is genuinely shared by multiple apps.
2. Docker builds need special handling. You can't just COPY . . in a Monorepo Dockerfile, or every app image includes all the code. Use turbo prune to generate a minimal build context:
turbo prune --scope=api --docker
3. Enable remote caching in CI. Local cache only helps individuals. For team collaboration, you need remote caching. Vercel offers a turnkey solution, or you can self-host a cache server.
Wrapping Up
Monorepo isn't a silver bullet, but when you genuinely need to share code across multiple apps, Turborepo offers a pragmatic solution. Its strengths are dependency-aware task orchestration, incremental build caching, and minimal configuration. If your team is struggling with version sync across multiple repos, it's worth a try.