"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck"
},
"devDependencies": {
"turbo": "^2.3.0",
"typescript": "^5.7.0"
}
}
### Step 2: Define the Directory Architecture
A production-ready monorepo strictly separates deployable applications from reusable internal libraries. This separation enforces architectural boundaries and prevents circular dependencies.
acme-platform/
βββ apps/
β βββ admin-panel/ # Next.js App Router (Internal Tool)
β βββ public-site/ # Next.js App Router (Marketing)
β βββ docs-portal/ # Nextra Documentation Site
βββ packages/
β βββ design-system/ # React Components & Tailwind Config
β βββ data-access/ # Prisma Schema & ORM Client
β βββ tsconfig/ # Shared TypeScript Compiler Options
β βββ eslint-config/ # Shared Linting Rules
βββ turbo.json # Task Graph & Caching Rules
βββ pnpm-workspace.yaml # Explicit workspace declarations
### Step 3: Build a Shared Library
Shared packages should expose explicit entry points. This prevents accidental deep imports and enables tree-shaking. Below is a production-grade design system package.
```json
// packages/design-system/package.json
{
"name": "@acme/design-system",
"version": "0.1.0",
"private": true,
"exports": {
"./button": "./src/components/Button.tsx",
"./card": "./src/components/Card.tsx",
"./tailwind": "./src/tailwind.config.ts",
"./styles": "./src/styles/globals.css"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^3.4.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"typescript": "^5.7.0"
}
}
The component implementation uses modern React patterns with explicit prop typing and CSS variable theming.
// packages/design-system/src/components/Button.tsx
import React from 'react';
type ButtonVariant = 'primary' | 'secondary' | 'ghost';
type ButtonSize = 'sm' | 'md' | 'lg';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
isLoading?: boolean;
}
const variantClasses: Record<ButtonVariant, string> = {
primary: 'bg-indigo-600 text-white hover:bg-indigo-700',
secondary: 'bg-slate-100 text-slate-900 hover:bg-slate-200',
ghost: 'bg-transparent text-slate-700 hover:bg-slate-50',
};
const sizeClasses: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
};
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'md',
isLoading = false,
children,
className = '',
disabled,
...props
}) => {
const base = 'inline-flex items-center justify-center font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
return (
<button
className={`${base} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? (
<span className="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
) : null}
{children}
</button>
);
};
Step 4: Consume in a Deployable Application
Applications import shared libraries using the workspace protocol. Turborepo resolves these locally during development and handles bundling during production builds.
// apps/admin-panel/src/app/dashboard/page.tsx
import { Button } from '@acme/design-system/button';
import { Card } from '@acme/design-system/card';
import { prisma } from '@acme/data-access';
export default async function DashboardView() {
const metrics = await prisma.metrics.findMany({
take: 5,
orderBy: { createdAt: 'desc' },
});
return (
<div className="p-8 space-y-6">
<header className="flex items-center justify-between">
<h1 className="text-2xl font-semibold tracking-tight">System Overview</h1>
<Button variant="primary" size="md">
Export Analytics
</Button>
</header>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{metrics.map((item) => (
<Card key={item.id} title={item.label} value={item.value} trend={item.trend} />
))}
</div>
</div>
);
}
Turborepo operates on a directed acyclic graph (DAG) of tasks. You must explicitly declare which tasks produce cacheable outputs and which tasks depend on others.
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"]
},
"typecheck": {
"dependsOn": ["^build"],
"outputs": []
}
}
}
Architecture Rationale:
dependsOn: ["^build"] ensures shared packages compile before applications consume them.
outputs defines cache keys. Turborepo hashes these paths; if unchanged, the task is skipped.
dev disables caching and runs persistently, enabling hot module replacement across workspace boundaries.
private: true in package manifests prevents accidental publication to public registries.
Pitfall Guide
Monorepos introduce new failure modes. The following pitfalls represent common production incidents and their resolutions.
1. Unbounded Task Graphs
Explanation: Failing to declare outputs or dependsOn forces Turborepo to run every task on every commit, negating incremental build benefits.
Fix: Always map task outputs explicitly. Use ^ prefix for upstream dependencies. Validate with turbo run build --dry.
2. TypeScript Project Reference Hell
Explanation: Shared packages compiled independently cause type-checking failures in consuming apps. TypeScript doesn't automatically track cross-package changes.
Fix: Enable composite: true and declaration: true in shared tsconfig.json files. Run tsc --build instead of tsc for incremental type checking.
3. Implicit Peer Dependencies
Explanation: A shared package imports react or tailwindcss without declaring it, causing runtime errors or duplicate bundling.
Fix: Declare framework dependencies in peerDependencies. Use pnpm why <package> to audit resolution paths.
4. Over-Abstraction in Shared Libraries
Explanation: Placing application-specific logic (e.g., route handlers, auth middleware) inside shared packages creates tight coupling and violates separation of concerns.
Fix: Enforce strict boundaries. Shared packages should expose pure components, utilities, or data models. Application logic stays in apps/.
5. Cache Invalidation Blind Spots
Explanation: Turborepo caches based on file hashes and environment variables. Ignoring globalEnv causes stale builds when secrets or feature flags change.
Fix: Add critical environment variables to turbo.json:
"globalEnv": ["DATABASE_URL", "NEXT_PUBLIC_API_URL", "NODE_ENV"]
6. Workspace Version Drift
Explanation: Using exact version numbers ("1.0.0") for local packages breaks local development when versions aren't updated manually.
Fix: Use the workspace:* protocol in dependencies. Turborepo and pnpm resolve it to the local path during development and lock to exact versions in CI.
7. Ignoring Bundle Size Impact
Explanation: Shared packages without explicit exports cause bundlers to include unused code, inflating client-side payloads.
Fix: Use exports in package.json. Run npx @next/bundle-analyzer on applications. Implement code-splitting for heavy utilities.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Startup MVP (1-2 apps) | Single Next.js App Router | Simpler deployment, lower infra overhead | Low |
| Multi-Brand Ecosystem | Turborepo + pnpm Workspaces | Shared design system, isolated deployments, atomic updates | Medium |
| Cross-Platform (Web + Mobile) | Turborepo + Expo/React Native | Shared business logic, platform-specific UI layers | High |
| High-Frequency UI Updates | Turborepo + Storybook Integration | Visual regression testing, component isolation, fast iteration | Medium |
| Enterprise Compliance | Turborepo + Private Registry + RBAC | Audit trails, version pinning, access control | High |
Configuration Template
Root turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"globalEnv": ["NODE_ENV", "DATABASE_URL", "NEXT_PUBLIC_BASE_URL"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**", "storybook-static/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"]
},
"typecheck": {
"dependsOn": ["^build"],
"outputs": []
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"]
}
}
}
Base tsconfig.json (packages/tsconfig/base.json)
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"composite": true,
"declaration": true,
"declarationMap": true,
"baseUrl": ".",
"paths": {
"@acme/design-system/*": ["../design-system/src/*"]
}
},
"exclude": ["node_modules", "dist", ".next"]
}
Quick Start Guide
- Install Dependencies: Run
pnpm install at the root. This creates symlinks for all workspace packages and installs strict dependencies.
- Initialize Turborepo: Execute
npx create-turbo@latest if starting fresh, or manually add turbo.json and update root scripts.
- Launch Development: Run
pnpm dev. Turborepo starts watch modes for all apps and packages simultaneously. Hot reload propagates across workspace boundaries.
- Verify Caching: Run
pnpm build twice. The second execution should complete in under 2 seconds, confirming task graph caching is active.
- Add Remote Cache: Configure
TURBO_TOKEN and TURBO_TEAM environment variables in your CI/CD pipeline to enable distributed caching across developer machines.
This architecture transforms frontend scaling from a coordination problem into an engineering system. By enforcing explicit boundaries, leveraging incremental compilation, and centralizing configuration, teams eliminate drift and accelerate delivery without sacrificing code quality or deployment independence.