pnpm Workspaces + Turborepo: Structuring a Full-Stack Monorepo
Abhishek Sharma
Software Developer
pnpm Workspaces + Turborepo: Structuring a Full-Stack Monorepo
When your product has a Next.js web app, a NestJS API, a Flutter mobile client, and a growing collection of shared packages, the question isn't whether you need a monorepo -- it's which tools you'll regret choosing six months from now. After running Errandoo's full-stack monorepo on pnpm workspaces with Turborepo for over a year, I want to walk through the exact configuration, the hard-won lessons, and the decisions I'd make differently.
Why pnpm Over npm or Yarn
The choice of pnpm wasn't aesthetic. npm and Yarn (classic) use flat node_modules structures that allow phantom dependencies -- packages your code imports but never declared in its own package.json. This works until it doesn't. A library you depend on drops a transitive dependency in a patch release, and suddenly your app breaks in CI but works locally because you still have the cached version.
pnpm solves this with a content-addressable store and symlinked node_modules. Each package can only access what it explicitly declares. On a monorepo with 6+ packages, this caught three phantom dependency bugs in the first week. The disk savings are real too -- our node_modules went from 1.8GB (npm) to 620MB (pnpm) because identical versions of the same package are hard-linked from a single global store instead of duplicated per workspace.
Yarn Berry (v4) with PnP offers similar strictness, but its compatibility story with tools like Jest, ESLint, and especially NestJS's decorator metadata is still rough. pnpm gave us strictness without requiring an ecosystem overhaul.
Workspace Structure
Here's the actual structure of the Errandoo monorepo:
errandoo/
├── apps/
│ ├── web/ # Next.js 14 customer dashboard
│ ├── api/ # NestJS backend
│ ├── rider-app/ # Flutter (non-Node)
│ └── admin/ # Next.js admin panel
├── packages/
│ ├── types/ # Shared TypeScript interfaces
│ ├── validators/ # Zod schemas (frontend + backend)
│ ├── config/ # Shared ESLint, TSConfig, Prettier
│ └── ui/ # Shared React components (web + admin)
├── pnpm-workspace.yaml
├── turbo.json
├── package.json # Root package.json
└── .npmrc
The pnpm-workspace.yaml is deceptively simple:
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
But the root .npmrc is where important behavior lives:
# .npmrc
strict-peer-dependencies=false
auto-install-peers=true
shamefully-hoist=false
link-workspace-packages=true
Setting shamefully-hoist=false is important. Some guides tell you to set it to true to fix compatibility issues, but that defeats the purpose of pnpm's strict isolation. When a package breaks without hoisting, the correct fix is to add the missing dependency to that package's package.json, not to weaken the strictness globally.
Shared Packages: The Core of the Monorepo
The Types Package
The @errandoo/types package holds all shared TypeScript interfaces and enums. The critical detail is the package.json and tsconfig.json configuration that makes this actually work across both Next.js and NestJS consumers:
// packages/types/package.json
{
"name": "@errandoo/types",
"version": "0.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./task": {
"types": "./src/task.ts",
"default": "./src/task.ts"
},
"./rider": {
"types": "./src/rider.ts",
"default": "./src/rider.ts"
}
},
"scripts": {
"lint": "eslint src/",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@errandoo/config": "workspace:*",
"typescript": "^5.4.0"
}
}
Notice the workspace:* protocol. This tells pnpm to always resolve this dependency from the local workspace, never from the registry. When you publish (if you ever need to), pnpm replaces workspace:* with the actual version. For private monorepos, workspace:* is simpler than pinning versions manually.
A common mistake is trying to build the types package into JavaScript. Don't. Both Next.js and NestJS can consume raw TypeScript from workspace packages directly. The main and types both point to ./src/index.ts, and the consuming app's bundler handles compilation. This eliminates a build step and avoids stale compiled output.
// packages/types/src/task.ts
export enum TaskStatus {
PENDING = "pending",
ASSIGNED = "assigned",
PICKED_UP = "picked_up",
IN_TRANSIT = "in_transit",
DELIVERED = "delivered",
CANCELLED = "cancelled",
}
export interface Task {
id: string;
customerId: string;
riderId: string | null;
status: TaskStatus;
pickup: Location;
dropoff: Location;
items: TaskItem[];
price: number;
createdAt: Date;
updatedAt: Date;
}
export interface Location {
lat: number;
lng: number;
address: string;
landmark?: string;
}
export interface TaskItem {
name: string;
quantity: number;
weight?: number;
specialInstructions?: string;
}
The Validators Package: Zod Schemas Shared Everywhere
This is where the monorepo pays for itself. The same Zod schema validates a form in the Next.js frontend and the request body in the NestJS backend. One source of truth, zero drift.
// packages/validators/src/task.ts
import { z } from "zod";
import { TaskStatus } from "@errandoo/types";
export const locationSchema = z.object({
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
address: z.string().min(5, "Address must be at least 5 characters"),
landmark: z.string().optional(),
});
export const createTaskSchema = z.object({
pickup: locationSchema,
dropoff: locationSchema,
items: z.array(
z.object({
name: z.string().min(1),
quantity: z.number().int().positive(),
weight: z.number().positive().optional(),
specialInstructions: z.string().max(500).optional(),
})
).min(1, "At least one item is required"),
scheduledFor: z.string().datetime().optional(),
paymentMethod: z.enum(["wallet", "cash", "upi"]),
});
// Infer the TypeScript type from the schema
export type CreateTaskInput = z.infer<typeof createTaskSchema>;
// Partial schema for updates
export const updateTaskSchema = createTaskSchema.partial().extend({
status: z.nativeEnum(TaskStatus).optional(),
});
export type UpdateTaskInput = z.infer<typeof updateTaskSchema>;
In the Next.js frontend, React Hook Form consumes these schemas directly:
// apps/web/components/create-task-form.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createTaskSchema, type CreateTaskInput } from "@errandoo/validators";
export function CreateTaskForm() {
const form = useForm<CreateTaskInput>({
resolver: zodResolver(createTaskSchema),
});
// ...
}
In the NestJS backend, the same schema powers a validation pipe:
// apps/api/src/common/pipes/zod-validation.pipe.ts
import { PipeTransform, BadRequestException } from "@nestjs/common";
import { ZodSchema } from "zod";
export class ZodValidationPipe implements PipeTransform {
constructor(private schema: ZodSchema) {}
transform(value: unknown) {
const result = this.schema.safeParse(value);
if (!result.success) {
throw new BadRequestException({
message: "Validation failed",
errors: result.error.flatten().fieldErrors,
});
}
return result.data;
}
}
// Usage in a controller:
@Post()
createTask(
@Body(new ZodValidationPipe(createTaskSchema)) dto: CreateTaskInput,
) {
return this.taskService.create(dto);
}
The Config Package: Shared Tooling
The config package exports base configurations that apps extend. This avoids duplicating 80 lines of ESLint rules across four packages.
// packages/config/package.json
{
"name": "@errandoo/config",
"version": "0.0.0",
"private": true,
"exports": {
"./eslint-next": "./eslint/next.js",
"./eslint-nest": "./eslint/nest.js",
"./tsconfig-base": "./tsconfig/base.json",
"./tsconfig-next": "./tsconfig/next.json",
"./tsconfig-nest": "./tsconfig/nest.json"
}
}
// packages/config/tsconfig/base.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"exclude": ["node_modules", "dist"]
}
Apps then extend with minimal overrides:
// apps/web/tsconfig.json
{
"extends": "@errandoo/config/tsconfig-next",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
Turborepo Pipeline Configuration
Turborepo's job is build orchestration: understanding the dependency graph between packages and running tasks in the right order with aggressive caching. Here's the actual turbo.json:
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": [".env*"],
"globalEnv": ["NODE_ENV", "DATABASE_URL"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**", "!.next/cache/**"],
"env": [
"NEXT_PUBLIC_API_URL",
"NEXT_PUBLIC_MAPBOX_TOKEN"
]
},
"dev": {
"dependsOn": ["^build"],
"persistent": true,
"cache": false
},
"lint": {
"dependsOn": ["^build"],
"outputs": []
},
"typecheck": {
"dependsOn": ["^build"],
"outputs": []
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"]
},
"db:generate": {
"cache": false
}
}
}
The "dependsOn": ["^build"] syntax is critical. The ^ means "run the build task in all upstream dependencies first." So if apps/web depends on @errandoo/types and @errandoo/validators, Turborepo builds those packages before building the web app. Without the caret, "dependsOn": ["build"] would mean "run my own build first" which creates a circular dependency.
The outputs array tells Turborepo what to cache. When inputs haven't changed, Turborepo replays the cached output instead of rebuilding. On our CI, this cuts the average build from 4 minutes to 45 seconds on cache hits. We exclude .next/cache/** because Next.js manages its own cache and including it in Turborepo's cache would cause stale ISR pages.
Handling Flutter in a Node Monorepo
Flutter doesn't use Node, npm, or pnpm. It has its own dependency management with pubspec.yaml. But it still benefits from living in the monorepo for shared documentation, unified CI, and code review workflows.
The trick is to include Flutter's directory in the workspace patterns but not give it a package.json with scripts that conflict with Turborepo. Instead, we use a thin package.json that defines Flutter-specific tasks Turborepo can orchestrate:
// apps/rider-app/package.json
{
"name": "@errandoo/rider-app",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "flutter build apk --release",
"test": "flutter test",
"lint": "flutter analyze",
"typecheck": "echo 'Dart handles its own type checking'"
}
}
This means turbo run build builds the Flutter APK alongside the Next.js and NestJS apps. The echo stub for typecheck prevents Turborepo from failing when it can't find the task. In CI, we install the Flutter SDK as a separate step before running the Turborepo pipeline.
CI/CD with GitHub Actions
Turborepo integrates with GitHub Actions through its remote caching feature and the --filter flag that only runs tasks for packages affected by the current PR:
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main, develop]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2 # Need parent commit for turbo diff
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- name: Build, lint, and typecheck (affected only)
run: |
pnpm turbo run build lint typecheck --filter=...[HEAD~1]
- name: Run tests (affected only)
run: |
pnpm turbo run test --filter=...[HEAD~1]
The --filter=...[HEAD~1] flag tells Turborepo to only process packages that changed since the last commit. If you only touched apps/web, it won't rebuild or retest apps/api. This is the single biggest CI time saver.
Turborepo vs Nx: Why I Chose Turborepo
Nx is more feature-rich. It has generators, executors, a dependency graph visualizer, and deeper integration with specific frameworks. But it's also more opinionated and heavier. For our use case, Turborepo's simplicity won out:
- Configuration: Turborepo is a single
turbo.jsonfile. Nx requiresnx.json,project.jsonper package, and often aworkspace.json. We have 8 packages -- the overhead isn't worth it. - Learning curve: New developers understand our Turborepo setup in 10 minutes. Nx's abstractions take longer to internalize.
- Escape hatch: Turborepo sits on top of your existing scripts. Removing it means running
pnpm --filter=web buildinstead ofturbo run build --filter=web. Removing Nx means rewriting your build commands. - Performance: For our scale (8 packages, ~4 minute builds), both are fast enough. Nx's distributed task execution matters at 50+ packages.
If you're managing 30+ packages with complex code generation needs, Nx is the better choice. Below that threshold, Turborepo's simplicity is a feature.
When a Monorepo Hurts More Than It Helps
I've seen teams adopt monorepos for the wrong reasons, and I want to be honest about the pain points:
- Different deployment cadences: If your frontend deploys 5x daily but your backend deploys weekly, a monorepo's coupled CI can slow the faster team down. We solved this with Turborepo's
--filter, but it adds complexity. - Team autonomy vs. consistency: A monorepo forces shared tooling. If Team A wants Vitest and Team B wants Jest, someone has to compromise. With separate repos, they can diverge. We decided consistency mattered more, but this is a legitimate trade-off.
- Git performance: At scale,
git statusslows down. We haven't hit this with our repo size, but companies like Google and Meta needed custom VFS solutions. If your repo exceeds ~5GB, consider this carefully. - Onboarding complexity: New developers need to understand workspaces, Turborepo, and the dependency graph before they can contribute. A single-purpose repo has a lower barrier to entry.
The monorepo works for Errandoo because the web app, API, and mobile app share significant business logic (types, validators, constants) and deploy from the same CI pipeline. If your services are genuinely independent with different teams and release cycles, separate repos with a shared package registry (npm private packages or Verdaccio) might serve you better.
Managing Node Versions Across Packages
One subtle issue: your Next.js app might need Node 20 for the latest features, while a legacy package still requires Node 18. We handle this with engines in each package's package.json and corepack for pnpm version pinning:
// Root package.json
{
"packageManager": "pnpm@10.4.0",
"engines": {
"node": ">=20.0.0"
}
}
// .npmrc
engine-strict=true
With engine-strict=true, pnpm refuses to install if the Node version doesn't match. Combined with corepack enable in CI and a .node-version file for local development, this ensures everyone runs the same environment. It's a small thing that prevents the "works on my machine" class of bugs entirely.
Key Takeaways
After a year of running this setup, these are the lessons that matter most: Use workspace:* for all internal dependencies and let pnpm handle resolution. Don't compile shared packages to JavaScript -- point main directly at TypeScript source and let the consuming bundler compile it. Make Zod your single source of truth for validation across frontend and backend. Keep Turborepo's turbo.json minimal and resist the urge to over-optimize caching before you have evidence of slow builds. And most importantly, only adopt a monorepo if you have shared code that genuinely needs to stay in sync. The tooling overhead isn't free.