Dev Tools & InfraTurborepoMonorepopnpmDevOps

Turborepo: Managing a Monorepo Without the Pain

Tasks, caching, and pipelines for a Next.js + Payload CMS pnpm monorepo — and the config mistakes to avoid.

March 31, 2026

turborepo monorepo guide

Monorepos are sold as the answer to cross-package coordination problems. In practice, they introduce a new class of problems: slow builds, unclear dependency graphs, and CI pipelines that rebuild everything on every commit. Turborepo solves the build performance problem. It doesn't solve the organizational ones — those are still on you.

What Turborepo Actually Does

Turborepo is a task runner for monorepos. It does two things:

  1. Caching — stores task outputs (build artifacts, type check results) and restores them when inputs haven't changed
  2. Parallelism — runs tasks concurrently across packages when their dependency graph allows it

That's it. It doesn't manage packages, it doesn't replace pnpm workspaces, and it doesn't have opinions about your code. It runs tasks fast.

The Setup

This project uses pnpm workspaces + Turborepo. The workspace config in package.json:

{
  "name": "blog-monorepo",
  "private": true,
  "workspaces": ["apps/*"],
  "devDependencies": {
    "turbo": "^2.0.0"
  }
}

The turbo.json at the repo root defines the task pipeline:

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "type-check": {
      "dependsOn": ["^build"]
    },
    "lint": {}
  }
}

Understanding dependsOn

The ^ prefix in dependsOn means "build all of this package's dependencies first":

"build": {
  "dependsOn": ["^build"]
}

This tells Turborepo: before building a package, build everything it depends on. For a monorepo where apps/web depends on a shared packages/ui, packages/ui builds first.

Without ^, Turborepo can run builds in parallel. With ^, it respects the dependency order.

Caching

Turborepo hashes your inputs (source files, env vars, dependencies) and caches the outputs. On the next run, if inputs haven't changed, it restores from cache instantly.

What counts as an input:

  • All files matched by the inputs config (defaults to all non-gitignored files)
  • The task's dependsOn tasks' outputs
  • Environment variables declared in env
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**"],
      "env": ["NODE_ENV", "NEXT_PUBLIC_PAYLOAD_URL", "NEXT_PUBLIC_TENANT_SLUG"]
    }
  }
}

If you don't list env vars in env, changing them won't invalidate the cache. This is a common mistake that causes stale builds after environment changes.

Running Tasks

# Run build for all packages
pnpm turbo build

# Run dev for all packages (in parallel)
pnpm turbo dev

# Run build for a specific package and its dependencies
pnpm turbo build --filter=web

# Run build, skipping packages that haven't changed since the last build
pnpm turbo build --filter=[HEAD^1]

The --filter flag is your best friend in large monorepos. In CI, --filter=[HEAD^1] builds only the packages that changed in the last commit.

Common Mistakes

Not declaring env vars in the task config. If your build reads environment variables and you don't list them in env, the cache won't invalidate when those vars change. You'll deploy with stale builds that don't reflect updated env vars.

Marking dev as cacheable. Dev servers are long-running and should never be cached. Always set "cache": false and "persistent": true on dev tasks.

Incorrect outputs patterns. If your outputs don't match what the task actually produces, remote caching won't work and you'll miss local cache hits. For Next.js, .next/** is correct — but exclude .next/cache/** (Next.js's own cache) to avoid caching a cache.

Circular dependencies. If package A depends on package B and package B depends on package A, Turborepo can't determine build order. Keep your dependency graph directed and acyclic.

CI Integration

In GitHub Actions, enable remote caching with Vercel's Turborepo Remote Cache:

- name: Build
  run: pnpm turbo build
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: ${{ vars.TURBO_TEAM }}

With remote caching, CI hits the same cache your local machine populated. A build that takes 3 minutes locally restores from cache in 10 seconds on CI.

Key Takeaways

  • Turborepo is a task runner; pnpm workspaces handles package management — they're complementary
  • List environment variables in task env config or cache invalidation won't work correctly
  • Use --filter in CI to build only changed packages; use [HEAD^1] for incremental builds
  • dev tasks should always have "cache": false, "persistent": true
  • Declare correct outputs patterns — wrong patterns mean cache misses on artifacts that did change