A monorepo holds multiple packages or applications in a single repository. Instead of maintaining separate repos for your web app, component library, and utility functions, you keep them together with shared configuration and tooling. Turborepo makes monorepos practical by caching task outputs and running only what changed.
Why use a monorepo?
When you have related packages, a monorepo offers several advantages:
| Benefit | Description |
|---|---|
| Code sharing | Import shared utilities and components directly without publishing to npm |
| Atomic commits | Change multiple packages in one commit with guaranteed consistency |
| Unified tooling | One ESLint config, one TypeScript config, one test runner |
| Simpler refactoring | Rename exports across packages in a single PR |
| Coordinated releases | Version and release packages together when needed |
The downside is complexity. Without proper tooling, a monorepo can become slow as npm install and npm run build run against every package. Turborepo solves this with intelligent caching and task orchestration.
Project structure
A typical Turborepo monorepo looks like this:
my-monorepo/
├── apps/
│ ├── web/ # Next.js web app
│ │ ├── package.json
│ │ └── src/
│ └── docs/ # Documentation site
│ ├── package.json
│ └── src/
├── packages/
│ ├── ui/ # Shared component library
│ │ ├── package.json
│ │ └── src/
│ ├── utils/ # Shared utilities
│ │ ├── package.json
│ │ └── src/
│ └── config/ # Shared configs (ESLint, TypeScript)
│ └── package.json
├── package.json # Root package.json with workspaces
├── turbo.json # Turborepo pipeline config
└── pnpm-workspace.yaml # If using pnpm
Each package has its own package.json with dependencies and scripts. The root package.json defines workspaces so the package manager can link local packages.
Setting up workspaces
For pnpm, create pnpm-workspace.yaml:
packages:
- 'apps/*'
- 'packages/*'
For npm or yarn, add to root package.json:
{
"workspaces": ["apps/*", "packages/*"]
}
Install dependencies from the root:
pnpm install
The package manager hoists shared dependencies and links local packages so @my-org/ui resolves to packages/ui.
Configuring Turborepo
Create turbo.json at the root:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"]
}
}
}
Key concepts:
| Concept | Meaning |
|---|---|
dependsOn: ["^build"] |
Run this package's build after all its dependencies' builds complete |
outputs |
Files to cache; if unchanged, skip the task |
cache: false |
Never cache this task (useful for dev servers) |
persistent: true |
Task runs indefinitely (dev servers) |
Running tasks
From the root, run tasks across all packages:
# Build all packages
turbo run build
# Lint all packages
turbo run lint
# Run tests
turbo run test
Turborepo figures out the dependency graph and runs tasks in parallel where possible. If packages/ui depends on packages/utils, Turborepo builds utils first.
Filtering by package
Run tasks for specific packages:
# Build only the web app and its dependencies
turbo run build --filter=web
# Build only packages that changed since main
turbo run build --filter=[main]
# Build packages matching a pattern
turbo run build --filter="./packages/*"
Filtering is useful in CI to run only affected tests after a change.
Caching in action
When you run turbo run build for the first time, Turborepo:
- Hashes all inputs (source files, dependencies, env vars)
- Runs the build script
- Stores outputs in
.turbo/cache
On subsequent runs, if the hash matches, Turborepo skips the build and restores cached outputs. This turns a 30-second build into instant replay.
# First run: 30s
turbo run build
# Second run (no changes): ~0.1s
turbo run build
# Output: ui:build: cache hit, replaying logs
Remote caching
Local caching only helps on one machine. Remote caching shares the cache across CI and team members.
For Vercel Remote Cache:
turbo login
turbo link
This connects your repo to Vercel's cache. When CI runs a build, it checks the remote cache first. If another CI run or team member already built that hash, CI downloads the cached output instead of rebuilding.
For self-hosted caching, run a Turborepo Remote Cache server or use S3/GCS with a custom adapter.
Environment variables
Turborepo can include environment variables in the hash:
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"env": ["NODE_ENV", "API_URL"]
}
}
}
If API_URL changes, the cache invalidates and the build reruns. This ensures different environments don't share incompatible caches.
Shared configurations
Keep ESLint, TypeScript, and Prettier configs in a shared package:
packages/config/
├── package.json
├── eslint.js
├── tsconfig.base.json
└── prettier.json
Reference them from other packages:
// apps/web/package.json
{
"devDependencies": {
"@my-org/config": "workspace:*"
}
}
// apps/web/.eslintrc.js
module.exports = require('@my-org/config/eslint');
Development workflow
Run all dev servers in parallel:
turbo run dev
Each app starts its dev server. Since cache: false and persistent: true, Turborepo keeps them running without caching.
For a single app:
turbo run dev --filter=web
Adding a new package
- Create the folder:
packages/new-lib/ - Add
package.jsonwith name@my-org/new-lib - Run
pnpm installto link it - Import from other packages:
import { foo } from '@my-org/new-lib'
No publishing needed. The monorepo links packages automatically.
CI configuration
In GitHub Actions:
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install
- run: pnpm turbo run build lint test --cache-dir=.turbo
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
With remote caching enabled, CI reuses builds from previous runs.
When not to use a monorepo
Monorepos add complexity. Avoid them when:
- You have one small project with no shared code
- Teams need strict access control between repos
- Projects have completely different tech stacks
- You are not ready to invest in tooling
For a single Next.js app with no shared packages, a simple single-package setup is easier.
Turborepo vs Nx
Both are monorepo tools. Turborepo focuses on simplicity and speed with minimal configuration. Nx offers more features: generators, plugins, and a computation cache for any task. Choose Turborepo for simpler setups; consider Nx for larger organizations with complex needs.
Summary
Turborepo makes monorepos practical by caching outputs and running tasks intelligently. Define your pipeline in turbo.json, run tasks from the root, and let Turborepo handle dependencies and caching. Add remote caching to share builds across CI and team members. Start simple with a few packages and scale as needed.
