# tutorials · monorepo
Turborepo tutorial: set up a real monorepo with pnpm workspaces.
Turborepo is a build orchestrator for JavaScript and TypeScript monorepos. It walks the task graph in parallel, caches per-task, and (with remote caching) shares that cache across your team and CI. This tutorial walks through a real setup: pnpm workspaces, a Next.js app, an Astro docs site, a shared UI package, remote caching on Vercel, and a GitHub Actions pipeline that halves CI time on the second run.
# what you need
Before you start.
This tutorial assumes Node 20 or later, and pnpm 9 installed globally. If you have not moved to pnpm yet, install it with corepack:
corepack enable
corepack prepare pnpm@latest --activate
pnpm --versionYou will also want a fresh empty directory. This tutorial is written against Turborepo 2.x, which changed the config schema in a few small ways from 1.x (notably pipeline is now tasks).
# workspace setup
Step 1: workspace layout with pnpm.
Turborepo does not replace your package manager. It sits on top of pnpm (or npm or yarn) workspaces and orchestrates the tasks. Start with the standard three-folder layout: apps/ for deployables, packages/ for shared code, and a root package.json that declares the workspace.
mkdir my-monorepo && cd my-monorepo
pnpm initEdit the root package.json to add the workspace declaration and the Turborepo dev dependency:
{
"name": "my-monorepo",
"private": true,
"packageManager": "[email protected]",
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test"
},
"devDependencies": {
"turbo": "^2.3.0"
}
}pnpm reads workspaces from pnpm-workspace.yaml, not from package.json. Create it at the root:
packages:
- 'apps/*'
- 'packages/*'Now install:
pnpm install
# turbo.json
Step 2: the task graph in turbo.json.
The turbo.json file at the root describes how tasks depend on each other. This is where Turborepo actually earns its keep: ^build means “build this package after every dependency has built”, and outputs tells Turborepo which files to cache.
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"],
"env": ["NODE_ENV"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"],
"outputs": []
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
}
}
}Two things worth flagging. First, persistent: true on dev tells Turborepo the task will not exit; without it, turbo run dev will run once and stop. Second, the env array is load-bearing for cache correctness: if your build reads NODE_ENV and it is not in the list, Turborepo will happily serve you the wrong cached output. Get this right early.
# shared package
Step 3: a shared UI package.
Create a shared package under packages/ui. This is where the monorepo pattern pays off: change the button component once, and every app that imports it picks it up on the next build.
mkdir -p packages/ui/src
cd packages/ui
pnpm initSet the packages/ui/package.json to be workspace-friendly:
{
"name": "@my-monorepo/ui",
"version": "0.0.0",
"private": true,
"main": "./src/index.tsx",
"types": "./src/index.tsx",
"scripts": {
"lint": "eslint src",
"build": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.5.0",
"@types/react": "^18.3.0",
"react": "^18.3.0"
}
}Now, in an app, add the shared package as a dependency using the workspace protocol:
{
"dependencies": {
"@my-monorepo/ui": "workspace:*"
}
}Run pnpm install at the root; pnpm symlinks the local package. Import it as any other npm package, and Turborepo will rebuild it before the app on turbo run build.
The remote cache is where Turborepo goes from “nice orchestrator” to “our CI runs in three minutes instead of nine”.
# remote caching
Step 4: Vercel remote cache in ninety seconds.
Local caching alone will save you time on your own machine. Remote caching is what saves the team money. Turborepo ships with a Vercel-hosted remote cache that is free for personal use and included on paid Vercel plans.
npx turbo login
npx turbo linkThat is the entire setup. turbo login opens a browser to authenticate, turbo link pairs the current repo to a Vercel scope. From now on, every turbo run build that produces a cache hit locally will also push to and pull from the remote cache. Your colleague pulls the branch, runs turbo run build, and hits the cache immediately.
If you would rather self-host the cache (an option if you are cautious about a build vendor holding your build artifacts), see the official Turborepo remote caching docs and the community-maintained ducktors/turborepo-remote-cache project. Both are viable in production.
# CI pipeline
Step 5: GitHub Actions with cache mounts.
The final piece is CI. GitHub Actions runs each job in a clean container, so the local Turborepo cache is gone on every push. The fix is to mount the remote cache via environment variables, then Actions picks up cached outputs the same way your laptop does.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run lint test build
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}Add TURBO_TOKEN as a repo secret (generate it in your Vercel account settings) and TURBO_TEAM as a repo variable. The fetch-depth: 2 line matters: Turborepo needs the previous commit to work out which packages changed for turbo run --filter=[HEAD^1] patterns.
One caveat: if your CI is on GitLab or self-hosted runners, the same env vars work; the token authenticates against Vercel regardless of runner. If you self-host the cache, swap in TURBO_API pointing at your own cache endpoint.
# where it breaks
Where Turborepo breaks (and the fix).
A few things will trip you up in the first week.
- Cache misses on env vars you did not declare. If your build reads an env var (say,
NEXT_PUBLIC_API_URL) and it is not inturbo.jsonunderenvorglobalEnv, Turborepo cannot know it changed and will serve stale output. Symptom: production builds look right locally, wrong in CI. - Dependency cycles across packages. If
packages/uiimports frompackages/utilsandpackages/utilsimports frompackages/ui, Turborepo will refuse to build. Break the cycle: extract shared types into a third package. - pnpm hoisting differences. Some tools (Storybook, older versions of React Native) expect flat
node_modules. pnpm uses a symlinked structure by default. Fix with a.npmrcat the workspace root:public-hoist-pattern[]=*, though be aware you lose some of pnpm’s isolation benefits. - Persistent tasks and CI. Never run
turbo run devin CI; thepersistent: trueflag will hang your pipeline. Use a separateturbo run build lint testcommand for CI.
# tl;dr
The short version.
Turborepo pays off when you have three or more packages sharing a config and you are already using pnpm workspaces. Set up the task graph in turbo.json, wire remote caching with turbo login && turbo link, and add TURBO_TOKEN to CI. Cache misses on undeclared env vars are the single most common trap.
More monorepo tooling coverage on our tutorials hub. If you are choosing between Turborepo, Nx, and Rush, we are shipping a comparison post in wave 1c. For hosting the monorepo’s CI cache, our hosting reviews cover the trade-offs between Vercel, Cloudways, and DigitalOcean. Full official documentation at turbo.build/repo/docs.
