Turborepo: Scaling Your TypeScript Monorepo
As your project grows from one app to multiple apps sharing packages, a monorepo becomes the natural architecture. Turborepo makes monorepos fast and manageable by orchestrating builds, caching results, and running tasks in parallel.
The Structure
A typical Turborepo project:
my-monorepo/
├── turbo.json # Turborepo config
├── package.json # Root package.json (workspaces)
├── pnpm-workspace.yaml # pnpm workspace config
├── apps/
│ ├── web/ # Next.js frontend
│ └── api/ # Hono API server
└── packages/
├── ui/ # Shared component library
├── db/ # Database schema & client
├── eslint-config/ # Shared ESLint configs
└── typescript-config/ # Shared tsconfig
Each folder under apps/ and packages/ is a workspace with its own package.json. They reference each other with "@repo/ui": "workspace:*".
Task Pipeline
turbo.json defines how tasks relate to each other:
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**"]
},
"lint": {
"dependsOn": ["^build"]
},
"check-types": {
"dependsOn": ["^build"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}
The ^build syntax means "build my dependencies first." When you run pnpm build, Turbo:
-
Analyzes the dependency graph
-
Builds
@repo/dband@repo/uifirst (no deps on each other → parallel) -
Then builds
apps/webandapps/api(depend on packages → after step 2)
Caching
Turbo's killer feature is content-aware caching. It hashes:
-
Source files
-
Environment variables
-
Dependency versions
-
Task configuration
If nothing changed, it replays the cached output instead of re-running:
$ pnpm build
@repo/ui:build: cache hit, replaying logs...
@repo/db:build: cache hit, replaying logs...
apps/web:build: cache miss, executing...
In practice, rebuilding after a small change in one package takes seconds instead of minutes.
Remote Caching
Share the cache across your team and CI:
npx turbo login
npx turbo link
Now when a teammate pushes code, CI can reuse the cache from their local build. The remote cache stores compressed task outputs in the cloud.
Development Workflow
pnpm dev starts all apps simultaneously:
$ pnpm dev
@repo/db:dev: Watching for changes...
apps/api:dev: Server running on :4000
apps/web:dev: Ready on :3000
Turbo runs them in dependency order and keeps them running (persistent: true). Changes to @repo/ui trigger hot reload in apps/web automatically.
Filtering
Run tasks for specific packages:
# Only build the web app and its dependencies
pnpm turbo run build --filter=apps/web...
# Only lint changed packages since main
pnpm turbo run lint --filter='...[main]'
# Build a specific package
pnpm turbo run build --filter=@repo/ui
Sharing Code
Internal packages use TypeScript path aliases and exports:
{
"name": "@repo/ui",
"exports": {
"./button": "./src/components/button.tsx",
"./card": "./src/components/card.tsx"
}
}
Consuming apps import directly:
import { Button } from "@repo/ui/button";
import { Card } from "@repo/ui/card";
No build step needed for internal packages during development — the consuming app's bundler compiles them directly.
When to Use a Monorepo
Good fit: Multiple apps sharing UI components, database schemas, or business logic. Cross-project refactoring. Consistent tooling and dependencies.
Bad fit: Unrelated projects. Teams that don't collaborate. Projects with vastly different tech stacks.
Turborepo is not about putting everything in one repo — it's about making shared code across related projects frictionless. The caching alone pays for the setup cost within the first week.