Skip to main content
btheo.com btheo.com > press start to play
NEW POST: NODE.JS SECURITY 2025 OPEN FOR FREELANCE 10+ YEARS EXP REACT × NODE × AWS NEW POST: NODE.JS SECURITY 2025 OPEN FOR FREELANCE 10+ YEARS EXP REACT × NODE × AWS
MONOREPO 4 MIN READ

Monorepos With Turborepo and pnpm: A Setup Guide

WARNING · DRAGON AHEAD

Your codebase is growing. Shared types. Shared UI components. Multiple apps that deploy together. You’ve hit the monorepo moment.

Most teams wait until pain is unbearable. Don’t. A production-ready monorepo at 2 applications scales to 20 with barely any friction. Without one, you’ll spend weeks managing version mismatches and duplicated logic.

When You Actually Need a Monorepo

Multiple apps share types, UI components, or config — a single source of truth beats npm private packages
Apps deploy together — coordinating releases across repos is a nightmare
Shared developer experience — linting, testing, and formatting rules in one place

⚠️ Not just because you have multiple projects. Monorepos have real overhead: circular dependency traps, CI complexity, tooling lock-in. If your apps are truly independent, keep them separate.

pnpm Workspaces Setup

pnpm is the only choice here. npm workspaces are barebones, yarn is legacy. pnpm’s strict phantom dependency prevention catches real bugs.

Create pnpm-workspace.yaml at the root:

packages:
- "packages/*"
- "apps/*"

Your directory tree:

root/
packages/
ui/ # Shared components
config/ # ESLint, tsconfig, etc
types/ # Shared TypeScript types
apps/
web/ # Next.js app
api/ # Node.js server
pnpm-workspace.yaml
pnpm-lock.yaml
turbo.json

Each package has its own package.json:

{
"name": "@monorepo/ui",
"version": "1.0.0",
"type": "module",
"exports": {
".": "./dist/index.js",
"./components": "./dist/components.js"
}
}

Install once at root: pnpm install. Dependencies are deduplicated globally. Apps depend on packages via:

{
"dependencies": {
"@monorepo/ui": "workspace:*",
"@monorepo/types": "workspace:*"
}
}

The workspace:* protocol means “use the local version, always.”

Turborepo: The Task Graph Engine

Turborepo runs your build, lint, and test tasks only once per change, across all packages, respecting dependencies.

Create turbo.json at root:

{
"version": "1",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"],
"cache": true
},
"lint": {
"outputs": []
},
"test": {
"outputs": ["coverage/**"],
"cache": true
},
"dev": {
"cache": false,
"persistent": true
}
},
"globalDependencies": ["tsconfig.json"]
}

What dependsOn: ["^build"] means: “Before building this package, build all its dependencies.”

If you change a file in packages/types, Turborepo rebuilds packages/types, then only apps/web and apps/api (because they depend on types). Everything else is skipped.

Run it:

Terminal window
turbo run build # Build everything
turbo run build --filter @monorepo/ui # Build only ui
turbo run test --parallel # Run all tests at once

Shared Packages Pattern

Config Package: One Source of Truth

packages/config/
src/
eslint.js
prettier.js
tsconfig.json
package.json

packages/config/src/eslint.js:

export default {
extends: ["eslint:recommended"],
parser: "@typescript-eslint/parser",
rules: {
"no-var": "error",
"prefer-const": "error"
}
};

In apps/web/.eslintrc.cjs:

module.exports = require("@monorepo/config/eslint");

One config. Twenty apps.

Types Package: Shared Data Structures

packages/types/
src/
index.ts
package.json
export interface User {
id: string;
email: string;
}
export interface ApiResponse<T> {
data: T;
error?: string;
}

Both apps/web and apps/api import from @monorepo/types. Never duplicate a type definition.

UI Package: Shared Components

packages/ui/
src/
Button.tsx
Card.tsx
package.json

Publish to npm or keep internal. Both are valid. The monorepo doesn’t care.

Caching: The Real Win

Turborepo caching saves hours of rebuild time. On CI, a developer changes a button color. Without caching, lint/test/build everything. With caching, lint/test/build skips untouched packages.

Local cache: stored in .turbo/ (git-ignored)
Remote cache: Vercel or self-hosted

Self-Hosted Remote Cache

Terminal window
turbo login
turbo link

Now turbo run build checks the cache before running. If the exact inputs (source + deps + config) match a previous run, use the cached outputs instead.

On CI:

- run: pnpm install
- run: turbo run build --api="$TURBO_API" --token="$TURBO_TOKEN"

A PR that touches only docs? Cache hit. Zero builds.

Task Graph Visualization

Terminal window
turbo run build --graph

Shows which tasks depend on which. Catches your circular dependency mistakes before production.

Real Gotchas

Circular Dependencies

Package A imports from Package B imports from Package A. Webpack will bundle it, then fail at runtime.

Fix: Restructure. Put shared types in a third package with zero dependencies.

Version Mismatches

packages/ui requires react@18, but apps/web has react@17 locked. Turbo won’t catch this.

Fix: Specify peer dependencies explicitly. Add a pnpm.overrides in root package.json to pin versions globally:

{
"pnpm": {
"overrides": {
"react": "18.2.0"
}
}
}

CI Cache Setup

GitHub Actions cache invalidates on lock file change. That’s correct. But if your turbo.json changes, you need a new cache key.

- uses: actions/cache@v3
with:
path: |
~/.pnpm-store
.turbo
key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml', 'turbo.json') }}

Move Fast

A monorepo done right means:

  • One lint/test/build command for all apps
  • Shared types and components with zero publish friction
  • Task caching that skips untouched code
  • A dependency graph you can actually see

Start here. Scale to 20 apps. Never look back.

ALL POSTS →