As an engineering organization expands, product development rarely remains confined to a single standalone web application. A family of interconnected platforms naturally emerges: a public customer portal (B2C), an authenticated administrative console (B2B Admin Dashboard), customer-facing documentation, landing pages, and marketing microsites. Housing these codebases in disconnected, isolated polyrepo repositories generates massive code duplication: design systems (UI Kits), domain TypeScript contracts, linter configurations, and business utilities are duplicated across repositories, and changing a single core button requires dozens of coordinated pull requests.
A Monorepo resolves this operational overhead by centralizing the codebase within a unified repository. Internal packages are linked directly via local workspace protocols without requiring intermediate package releases to public or private npm registries.
However, monorepos lacking intelligent build systems suffer from severe build performance degradation: editing a single documentation markdown file must never trigger a complete rebuild of the enterprise web application and admin dashboard. To orchestrate tasks, resolve dependency graphs, and compute cache invalidations, modern architectures adopt Turborepo. Turborepo prevents redundant computation through cryptographic hashing and Remote Caching.
In a monorepo architecture powered by pnpm Workspaces, a clear separation of concerns is maintained:
apps/* — Terminal consumers (Next.js applications). They import shared packages, but no external package or application ever imports code from apps/.packages/* — Shared reusable libraries (UI components, domain schemas, shared configurations, database ORM clients).MONOREPO ROOT
┌─────────────────────────────────────────────────┐
│ pnpm-workspace.yaml | turbo.json | package.json │
└────────────────────────┬────────────────────────┘
│
┌───────────────────────┴───────────────────────┐
▼ ▼
[apps/ (Consumers)] [packages/ (Libraries)]
┌───────────────────────────────┐ ┌─────────────────────────────────┐
│ apps/web (Next.js 15 App) │ │ packages/ui (Design System) │
│ apps/admin (Next.js Admin) │ ────────────► │ packages/types (Interfaces) │
│ apps/docs (Documentation) │ workspace:* │ packages/tsconfig (TS Configs) │
└───────────────────────────────┘ │ packages/database (Prisma/ORM) │
│ └─────────────────────────────────┘
│ turbo prune ▲
▼ │
┌───────────────────────────────┐ │
│ Docker OCI Image (@acme/web) │ ──────────────────────────────┘
│ (Contains strictly web + ui) │
└───────────────────────────────┘Author a Modal component in packages/ui, exporting it via package.json exports.
Add '@acme/ui': 'workspace:*' to the dependencies of consuming apps.
Add the package to transpilePackages in next.config.ts and configure @source in globals.css.
Verify topological task dependencies in turbo.json and run pnpm turbo check-types.
Execute pnpm turbo build, leveraging instant local and Remote Cache artifact storage.
A team develops a shared UI Kit in packages/ui within a pnpm monorepo. A developer adds react into the dependencies section of packages/ui/package.json. Upon importing components into a Next.js 15 application, the page crashes with 'Invalid Hook Call: Hooks can only be called inside the body of a function component'. What caused this failure and how do you resolve it?
pnpm-workspace.yaml#At the monorepo root, pnpm-workspace.yaml defines the discovery boundaries for workspace packages:
packages:
- "apps/*"
- "packages/*"turbo.json#Under Turborepo 2.x standards, the root configuration section is named tasks (replacing the deprecated pipeline), and environment variables must be declared explicitly to prevent Cache Poisoning:
{
"$schema": "https://turbo.build/schema.json",
"ui": "tui",
"globalEnv": ["NODE_ENV", "VERCEL_ENV"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"],
"env": ["NEXT_PUBLIC_API_URL", "DATABASE_URL"]
},
"lint": {
"dependsOn": ["^lint"]
},
"check-types": {
"dependsOn": ["^check-types"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}In packages/ui, React is placed strictly in peerDependencies to prevent multi-instance conflicts:
{
"name": "@acme/ui",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"react": "^19.0.0",
"typescript": "^5.7.0"
}
}Connecting the package inside an application (apps/web/package.json) uses the workspace:* protocol:
{
"name": "@acme/web",
"version": "0.1.0",
"private": true,
"dependencies": {
"@acme/ui": "workspace:*",
"next": "^15.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}transpilePackages#Because @acme/ui ships uncompiled TypeScript, the Next.js compiler (Turbopack) must transpile it on the fly:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
transpilePackages: ['@acme/ui'],
};
export default nextConfig;Rather than copying compiler options across projects, create a shared base package packages/typescript-config/nextjs.json:
{
"extends": "@acme/typescript-config/nextjs.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"]
}In Tailwind CSS v4, scanning utility classes in shared design system packages is achieved using the @source directive in the application's root stylesheet:
@import "tailwindcss";
/* Direct Tailwind v4 compiler to scan shared workspace package components */
@source "../../../../packages/ui/src/**/*.tsx";In a monorepo CI/CD pipeline, building the Docker image for apps/web takes 12 minutes. Furthermore, after changing NEXT_PUBLIC_API_URL in .env, production containers continue requesting the outdated test API endpoint. How do you resolve both issues using Turborepo?
If @acme/ui declares react in dependencies, the package manager installs an isolated React copy in packages/ui/node_modules. At runtime in Next.js, two conflicting React instances execute concurrently, breaking hook dispatchers (useState/useEffect).
Solution: Declare react and react-dom in shared libraries strictly as peerDependencies, and pin a single version in the root package.json using pnpm overrides:
{
"pnpm": {
"overrides": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
}If an application consumes NEXT_PUBLIC_API_URL, but the variable is omitted from the tasks.build.env array in turbo.json, Turborepo excludes its value from task hash computation. Changing .env values will result in Turborepo falsely declaring a cache hit and deploying a stale build containing the old API address.
Solution: Always list runtime and build-time environment variables in tasks.build.env in turbo.json.
Building an image for apps/web with COPY . . drags unrelated applications (apps/admin, apps/docs) into the container context, inflating image size by gigabytes and invalidating layer caches on unrelated commits.
Solution: Use turbo prune @acme/web --docker during the initial build stage to extract an isolated dependency slice.
Applications (apps/*) are terminal consumers. Importing utilities or components directly from apps/admin into apps/web compromises autonomous deployment and causes circular dependencies. Extract shared code to packages/.