{"schemaVersion":"1.0","type":"TechArticle","types":["Article","TechArticle"],"slug":"typescript-monorepos-with-turborepo-and-pnpm-the-setup-that-actually-works-193o3","url":"https://zyvop.com/typescript-monorepos-with-turborepo-and-pnpm-the-setup-that-actually-works-193o3","title":"TypeScript Monorepos with Turborepo and pnpm: The Setup That Actually Works","subtitle":"Build a scalable TypeScript monorepo with pnpm workspaces, Turborepo caching, shared packages, incremental builds, and production-ready Docker workflows.","tldr":"Monorepos are now mainstream, and for JS/TS teams managing 5–50 packages, Turborepo offers fast builds with minimal setup. This guide covers pnpm workspaces, shared configs, TypeScript project references, Turborepo pipelines, and Docker builds with turbo prune.","keywords":["TypeScript project references monorepo","Turborepo pnpm workspaces","Turborepo pipeline","monorepo Docker build","pnpm workspace tutorial","monorepo setup Node.js","TypeScript monorepo 2026"],"entities":["ZyVOP","TypeScript project references monorepo","Turborepo pnpm workspaces","Turborepo pipeline","monorepo Docker build","pnpm workspace tutorial","monorepo setup Node.js","TypeScript monorepo 2026"],"keyTakeaways":["The promise of a monorepo is simple: one repository, shared code, unified tooling, atomic changes across packages.","The reality, historically, was a fragile tangle of symlinks, build script order-of-operations problems, and TypeScript errors that only appeared in CI.","The tooling has caught up."],"headings":["When a Monorepo Makes Sense","The Structure","Step 1 — Initialize the Workspace","Step 2 — Shared Config Package","Step 3 — Shared Types Package","Step 4 — The API App","Step 5 — Turborepo Pipeline","Step 6 — Docker Build for Production","Step 7 — CI Pipeline","Adding a New Package"],"outboundLinks":[],"contentText":"The promise of a monorepo is simple: one repository, shared code, unified tooling, atomic changes across packages. The reality, historically, was a fragile tangle of symlinks, build script order-of-operations problems, and TypeScript errors that only appeared in CI. The tooling has caught up. In 2026, pnpm workspaces + Turborepo + TypeScript Project References is the stack that actually works — fast, well-documented, and production-tested at scale. Monorepos are now used by 63% of companies with 50+ developers, and for JavaScript/TypeScript teams managing between 5 and 50 packages, Turborepo is the practical choice. This guide builds the full setup from scratch: workspace structure, shared packages, TypeScript project references, Turborepo pipelines, and Docker builds that work correctly with monorepo dependencies. When a Monorepo Makes Sense Before setting one up, be honest about whether you need it. Use a monorepo when: You have shared code (types, utilities, UI components) used by multiple apps You make changes that span multiple packages atomically (API contract change + client update) You are tired of version drift between a shared library and the apps that consume it Your team works across packages regularly and context-switching between repos is friction Stick with separate repos when: Each service is truly independent with no shared code Different teams own different services and need independent deployment cadences You have fewer than 3 packages — the overhead is not worth it yet The Structure / ├── apps/ │ ├── api/ # Node.js Express API │ └── web/ # Frontend (Next.js, etc.) ├── packages/ │ ├── types/ # Shared TypeScript types │ ├── utils/ # Shared utility functions │ ├── ui/ # Shared UI components (if applicable) │ └── config/ # Shared ESLint, TypeScript configs ├── package.json # Root — workspace definition ├── pnpm-workspace.yaml └── turbo.jsonStep 1 — Initialize the Workspace # Install pnpm if you haven't npm install -g pnpm mkdir my-monorepo &amp;&amp; cd my-monorepo pnpm init# pnpm-workspace.yaml packages: - 'apps/*' - 'packages/*' // package.json (root) { \"name\": \"my-monorepo\", \"private\": true, \"scripts\": { \"build\": \"turbo build\", \"dev\": \"turbo dev\", \"lint\": \"turbo lint\", \"test\": \"turbo test\", \"typecheck\": \"turbo typecheck\" }, \"devDependencies\": { \"turbo\": \"latest\", \"typescript\": \"^5.5.0\" }, \"engines\": { \"node\": \"&gt;=20\", \"pnpm\": \"&gt;=9\" } }pnpm add -D turbo -w # -w installs to workspace rootStep 2 — Shared Config Package Start with the config package — everything else depends on it. mkdir -p packages/config cd packages/config pnpm init // packages/config/package.json { \"name\": \"@myrepo/config\", \"version\": \"0.0.1\", \"private\": true, \"exports\": { \"./tsconfig\": \"./tsconfig.base.json\", \"./tsconfig/*\": \"./tsconfig.*.json\" } }// packages/config/tsconfig.base.json { \"compilerOptions\": { \"target\": \"ES2022\", \"module\": \"Node16\", \"moduleResolution\":\"Node16\", \"lib\": [\"ES2022\"], \"strict\": true, \"exactOptionalPropertyTypes\": true, \"noUncheckedIndexedAccess\": true, \"declaration\": true, \"declarationMap\": true, \"sourceMap\": true, \"esModuleInterop\": true, \"skipLibCheck\": true } }// packages/config/tsconfig.node.json — for Node.js packages { \"extends\": \"./tsconfig.base.json\", \"compilerOptions\": { \"outDir\": \"dist\", \"rootDir\": \"src\" }, \"exclude\": [\"node_modules\", \"dist\", \"**/*.test.ts\"] }Step 3 — Shared Types Package mkdir -p packages/types/src// packages/types/package.json { \"name\": \"@myrepo/types\", \"version\": \"0.0.1\", \"private\": true, \"main\": \"./dist/index.js\", \"types\": \"./dist/index.d.ts\", \"exports\": { \".\": { \"import\": \"./dist/index.js\", \"types\": \"./dist/index.d.ts\" } }, \"scripts\": { \"build\": \"tsc\", \"typecheck\": \"tsc --noEmit\", \"dev\": \"tsc --watch\" }, \"devDependencies\": { \"@myrepo/config\": \"workspace:*\", \"typescript\": \"^5.5.0\" } }// packages/types/tsconfig.json { \"extends\": \"@myrepo/config/tsconfig\", \"compilerOptions\": { \"outDir\": \"./dist\", \"rootDir\": \"./src\" }, \"include\": [\"src\"] }// packages/types/src/index.ts export interface User { id: string; email: string; fullName: string; role: 'user' | 'admin'; createdAt: Date; } export interface Order { id: string; userId: string; status: 'pending' | 'paid' | 'shipped' | 'cancelled'; total: number; createdAt: Date; } export interface ApiResponse&lt;T&gt; { data: T; meta?: Record&lt;string, unknown&gt;; } export interface PaginatedResponse&lt;T&gt; { data: T[]; pagination: { hasNextPage: boolean; nextCursor: string | null; }; }Step 4 — The API App mkdir -p apps/api/src// apps/api/package.json { \"name\": \"@myrepo/api\", \"version\": \"0.0.1\", \"private\": true, \"scripts\": { \"build\": \"tsc\", \"dev\": \"tsx watch src/server.ts\", \"start\": \"node dist/server.js\", \"typecheck\": \"tsc --noEmit\", \"test\": \"vitest run\" }, \"dependencies\": { \"@myrepo/types\": \"workspace:*\", \"@myrepo/utils\": \"workspace:*\", \"express\": \"^4.21.0\" }, \"devDependencies\": { \"@myrepo/config\": \"workspace:*\", \"typescript\": \"^5.5.0\", \"tsx\": \"^4.0.0\" } }// apps/api/tsconfig.json { \"extends\": \"@myrepo/config/tsconfig\", \"compilerOptions\": { \"outDir\": \"./dist\", \"rootDir\": \"./src\", // Project references — tells TypeScript about dependencies // Enables incremental builds — only rebuilds what changed }, \"references\": [ { \"path\": \"../../packages/types\" }, { \"path\": \"../../packages/utils\" } ], \"include\": [\"src\"] }// apps/api/src/server.ts import express from 'express'; import type { User } from '@myrepo/types'; // From the shared types package import { formatDate } from '@myrepo/utils'; // From the shared utils package const app = express(); app.use(express.json()); app.get('/users/:id', async (req, res) =&gt; { const user: User = await getUserById(req.params.id); res.json({ data: { ...user, createdAt: formatDate(user.createdAt) } }); }); app.listen(3000);Step 5 — Turborepo Pipeline // turbo.json { \"$schema\": \"https://turbo.build/schema.json\", \"globalDependencies\": [\"**/.env.*local\"], \"pipeline\": { \"build\": { \"dependsOn\": [\"^build\"], // ^ means: build dependencies first \"outputs\": [\"dist/**\"], \"inputs\": [\"src/**\", \"tsconfig.json\", \"package.json\"] }, \"dev\": { \"dependsOn\": [\"^build\"], // Ensure deps are built before dev server starts \"cache\": false, // Never cache dev \"persistent\": true // Long-running process }, \"test\": { \"dependsOn\": [\"^build\"], \"outputs\": [\"coverage/**\"], \"inputs\": [\"src/**\", \"tests/**\"] }, \"typecheck\": { \"dependsOn\": [\"^build\"], \"outputs\": [] }, \"lint\": { \"outputs\": [] } } }The ^build in dependsOn is the key: it tells Turborepo \"before running this task, run build in all dependency packages.\" So when you build @myrepo/api, Turborepo automatically builds @myrepo/types and @myrepo/utils first. In parallel where possible. Running tasks: # Build everything in the right order pnpm turbo build # Build only the API and its dependencies pnpm turbo build --filter=@myrepo/api # Run dev servers for all apps in parallel pnpm turbo dev # Only run what changed since main branch pnpm turbo test --filter=...[origin/main] # Run with remote caching (Vercel) pnpm turbo build --token=$TURBO_TOKEN --team=$TURBO_TEAMStep 6 — Docker Build for Production The tricky part with monorepos and Docker is that COPY . . copies the entire monorepo into the build context. For a large monorepo this is slow and the resulting image is huge. Turborepo's prune command solves this — it generates a minimal subset of the monorepo with only the files needed for a specific app. # Generate a pruned version of the monorepo for the API npx turbo prune @myrepo/api --dockerThis creates an out/ directory with: out/json/ — just the package.json files (for dependency installation) out/full/ — the complete pruned source # apps/api/Dockerfile FROM node:20-alpine AS base RUN npm install -g pnpm # Stage 1: Install dependencies (cached unless package.json changes) FROM base AS deps WORKDIR /app # Copy only package.json files first — layer cache for dependency install COPY out/json/ . COPY pnpm-workspace.yaml ./ RUN pnpm install --frozen-lockfile # Stage 2: Build FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY out/full/ . RUN pnpm turbo build --filter=@myrepo/api # Stage 3: Production image FROM node:20-alpine AS production RUN addgroup -S appgroup &amp;&amp; adduser -S appuser -G appgroup WORKDIR /app COPY --from=builder /app/apps/api/dist ./dist COPY --from=builder /app/apps/api/package.json ./ COPY --from=builder /app/node_modules ./node_modules USER appuser EXPOSE 3000 CMD [\"node\", \"dist/server.js\"]Build it: # From the repo root npx turbo prune @myrepo/api --docker docker build -f apps/api/Dockerfile -t myrepo-api .Step 7 — CI Pipeline # .github/workflows/ci.yml name: CI on: [push, pull_request] jobs: build-and-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 2 # Needed for --filter=[HEAD^1] to work - uses: pnpm/action-setup@v3 with: version: 9 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Typecheck run: pnpm turbo typecheck - name: Lint run: pnpm turbo lint - name: Test run: pnpm turbo test - name: Build run: pnpm turbo build env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }}With remote caching enabled, Turborepo skips tasks whose inputs have not changed since the last run — even across different machines. Remote caching can make builds up to 10x faster, which matters in CI where you pay per minute. Adding a New Package The pattern for adding any new shared package: mkdir -p packages/logger/src cd packages/logger # Create package.json with name @myrepo/logger # Create tsconfig.json extending @myrepo/config/tsconfig # Write your code in src/ # Install it in apps that need it cd ../../apps/api pnpm add @myrepo/logger --workspaceThat's it. pnpm handles the symlink. Turborepo detects the dependency from package.json and includes it in the build graph automatically.","contentHash":"sha256:4a53d7f7f6e98a67619d41db4985dfdb6a53450201505a56f25d5a46d8377bb0","authorName":"ZyVOP","authorUrl":"https://zyvop.com/author/zyvop","authorSameAs":["https://zyvop.com","https://github.com/zyvop","https://x.com/zyvop1"],"category":null,"tags":["TypeScript project references monorepo","Turborepo pnpm workspaces","Turborepo pipeline","monorepo Docker build","pnpm workspace tutorial","monorepo setup Node.js","TypeScript monorepo 2026"],"audience":"Developers, software engineers, and students learning TypeScript project references monorepo","tone":"Instructional, practical, code-first","readingTimeMinutes":6,"wordCount":1332,"faqs":null,"primaryTopic":"TypeScript project references monorepo","publishedAt":"2026-05-27T07:43:34.257Z","updatedAt":"2026-08-18T07:45:00.208Z","canonicalUrl":"https://zyvop.com/typescript-monorepos-with-turborepo-and-pnpm-the-setup-that-actually-works-193o3"}