Turborepo tutorial: set up a real monorepo with pnpm workspaces

# 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 --version

You 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 init

Edit 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 init

Set 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 link

That 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 in turbo.json under env or globalEnv, 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/ui imports from packages/utils and packages/utils imports from packages/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 .npmrc at 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 dev in CI; the persistent: true flag will hang your pipeline. Use a separate turbo run build lint test command 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.


affiliate disclosure: Web Dev Blog carries affiliate links to Kinsta, WP Engine, Cloudways, DigitalOcean, and Vercel. If you sign up via a link on this site we may receive a commission at no cost to you. This does not influence editorial verdicts, which are based on real tests from a London server. Full policy on /affiliate-disclosure/.