Build a JavaScript Monorepo with Turborepo and pnpm Workspaces
Wire up a pnpm workspace monorepo with Turborepo task orchestration and local caching — then extend it with a shared utility package and optional remote cache for CI.
What You'll Build
A pnpm workspace monorepo with Turborepo orchestrating builds across two Next.js apps and a shared TypeScript config package. After the first full build, re-running against unchanged code completes in milliseconds via local cache — remote caching extends that to every CI machine and teammate.
Prerequisites
- Node.js 18 or 20 LTS — verify with
node --version - pnpm 9+:
corepack enable && corepack prepare pnpm@latest --activate - A free Vercel account for remote caching (optional; covered in step 6)
- Comfort with TypeScript and how npm packages resolve
1. Scaffold with create-turbo
pnpm dlx create-turbo@latest my-monorepo
cd my-monorepo
pnpm install
When prompted, select pnpm as the package manager. The resulting layout:
my-monorepo/
├── apps/
│ ├── web/ # Next.js app
│ └── docs/ # Next.js docs site
├── packages/
│ ├── ui/ # Shared React components
│ ├── eslint-config/ # Shared ESLint rules
│ └── typescript-config/ # Shared tsconfig bases
├── turbo.json
├── pnpm-workspace.yaml
└── package.json
2. Understand the Workspace Wiring
pnpm-workspace.yaml tells pnpm which directories are packages:
packages:
- "apps/*"
- "packages/*"
Internal packages reference each other with the workspace protocol so pnpm never hits the registry for them:
// apps/web/package.json (excerpt)
{
"dependencies": {
"@repo/ui": "workspace:*"
}
}
workspace:* always resolves to the local version — never a published release.
3. Review turbo.json
Turborepo 2.x uses a tasks key (the older pipeline key is removed):
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"lint": {},
"dev": {
"cache": false,
"persistent": true
}
}
}
| Key | Effect |
|---|---|
"^build" |
Run build in all dependency packages before this one |
outputs |
Files Turbo stores and restores on a cache hit |
"cache": false |
Dev servers produce no cacheable artifact |
"persistent": true |
Task never exits; Turbo won't block the rest of the pipeline |
4. Add a Shared Utility Package
mkdir -p packages/utils/src
packages/utils/package.json:
{
"name": "@repo/utils",
"version": "0.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"typescript": "^5.0.0"
}
}
packages/utils/tsconfig.json:
{
"extends": "@repo/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
packages/utils/src/index.ts:
export function formatDate(date: Date): string {
return date.toISOString().split("T")[0];
}
Add the package to apps/web, then use it. Quote the version specifier to prevent shell glob expansion of *:
pnpm --filter web add '@repo/utils@workspace:*'
// apps/web/app/page.tsx
import { formatDate } from "@repo/utils";
export default function Page() {
return <p>Today: {formatDate(new Date())}</p>;
}
5. Run Your First Build
pnpm exec turbo run build
Turbo resolves the dependency graph automatically: typescript-config and utils compile before web and docs. The first run is a cache miss for every task. Run it again immediately:
pnpm exec turbo run build
Every task now shows cache hit, replaying output. The full build finishes in milliseconds.
6. Enable Remote Caching
Remote caching shares the cache across your team and CI. Using Turbo's Vercel-hosted cache:
pnpm exec turbo login
pnpm exec turbo link
turbo login stores your auth token globally in ~/.turbo/config.json. turbo link writes the linked team and project to the repo-local .turbo/config.json (already git-ignored by the scaffold). In CI, supply the same cache via environment variables instead:
# .github/workflows/ci.yml (excerpt)
- name: Build
run: pnpm exec turbo run build
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
TURBO_TOKEN is a personal access token from vercel.com/account/tokens. TURBO_TEAM is your Vercel team slug (e.g., acme-corp). Turbo detects both variables automatically — no other config changes needed.
Verify It Works
pnpm exec turbo run build --dry=json
--dry=json prints the resolved task graph without executing anything. Confirm that @repo/utils appears as a dependency of web in the tasks array. A successful real build ends with output similar to:
Tasks: 6 successful, 6 total
Cached: 6 cached, 6 total
Time: 289ms >>> FULL TURBO
FULL TURBO confirms every task was a cache hit.
Troubleshooting
ERR_PNPM_WORKSPACE_PKG_NOT_FOUND — A workspace:* dependency can't resolve. Verify the name field in the target's package.json matches the import exactly, then re-run pnpm install.
Cache never hits on the second run — The outputs glob doesn't cover what your build actually writes. Double-check that paths match real artifacts (dist/** for TypeScript packages, .next/** for Next.js apps). Output paths not listed in outputs won't be saved to or restored from the cache, so Turbo will re-run the task even when inputs haven't changed.
turbo: command not found in CI — turbo must be a root devDependency. Run pnpm add -Dw turbo and commit the updated lockfile. Always invoke it via pnpm exec turbo rather than relying on a globally installed binary.
Next.js build cache inflating artifacts — The !.next/cache/** exclusion in outputs is intentional. Next.js manages that directory internally; including it causes cache bloat and slower restores.
Next Steps
- Filter builds for deployment:
pnpm exec turbo run build --filter=web...builds onlyweband the packages it depends on — ideal for single-app deploy steps in CI. (The trailing...means "this package and its transitive dependencies"; the leading form...webmeans dependents ofweb, which is the opposite.) - Versioning and publishing: Add Changesets to manage semver and changelogs when packages inside the repo are published publicly.
- Scaffold new packages consistently:
pnpm exec turbo gencreates apps or packages from Plop-based templates defined inturbo/generators/config.ts. - Self-hosted remote cache: The Turborepo Remote Cache API is an open HTTP spec; open-source implementations back it with S3, R2, or GCS if Vercel's cloud cache doesn't meet your compliance requirements.
Mariana covers the fast-moving world of machine learning and generative AI, with a particular focus on how these technologies are reshaping development workflows. When she isn't stress-testing the latest foundation models, she's usually at a local hackathon.
Discussion 0
No comments yet
Be the first to weigh in.