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

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.
Turborepo is a task runner for monorepos. It does two things:
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.
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": {}
}
}
dependsOnThe ^ 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.
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:
inputs config (defaults to all non-gitignored files)dependsOn tasks' outputsenv{
"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.
# 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.
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.
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.
env config or cache invalidation won't work correctly--filter in CI to build only changed packages; use [HEAD^1] for incremental buildsdev tasks should always have "cache": false, "persistent": trueoutputs patterns — wrong patterns mean cache misses on artifacts that did change