Monorepo Architecture and Tooling
Why Monorepos Keep Winning
Vercel, Google, Meta, Microsoft, Uber -- all use monorepos. Not because it is trendy, but because the alternative (one repo per package) creates coordination overhead that scales worse than the code itself.
Imagine your codebase has a shared component library, a marketing site, an admin dashboard, and the main app. In separate repos, updating a Button component means: update the library repo, publish a new version, create PRs in three consuming repos to bump the version, wait for CI in all four repos, and pray nothing breaks across the version boundary. One button change. Four repos. Eight PRs. Half a day.
In a monorepo, you change the Button, run tests across all consumers in one command, and deploy everything from one commit. The change is atomic.
Think of a monorepo like a department store. Every department (packages) is under one roof. The shoe department can instantly check if the socks department has matching inventory. The lighting department can update all display cases at once. A polyrepo is a strip mall -- each store has its own building, its own hours, and getting them to coordinate a sale requires 15 phone calls.
The Core Benefits
Atomic Commits
One commit can change a shared library AND all its consumers. This eliminates "dependency hell" -- where package A needs version 2.0 of the library but package B is stuck on 1.8.
commit: "refactor: rename Button variant prop from type to variant"
packages/ui/src/button.tsx → rename prop
apps/web/src/components/header.tsx → update usage
apps/admin/src/pages/settings.tsx → update usage
apps/docs/src/examples/button.mdx → update example
All changes are atomic. No intermediate broken state. No version mismatch.
Shared Tooling
One ESLint config. One TypeScript config. One Prettier config. One CI pipeline. When you update a lint rule, it applies everywhere. No drift between repos.
monorepo/
packages/
eslint-config/ → shared ESLint rules
tsconfig/ → shared TypeScript configs
ui/ → design system components
apps/
web/ → main application
admin/ → admin dashboard
docs/ → documentation site
turbo.json → task orchestration
pnpm-workspace.yaml → workspace definition
Code Sharing Without Publishing
Internal packages are consumed directly, not through npm. No versioning, no publishing, no waiting for CI to publish a new version.
{
"name": "@acme/web",
"dependencies": {
"@acme/ui": "workspace:*",
"@acme/utils": "workspace:*",
"@acme/tsconfig": "workspace:*"
}
}
workspace:* tells pnpm to resolve @acme/ui from the local workspace, not npm. Changes to @acme/ui are immediately available to @acme/web without any publish step.
Turborepo vs Nx
The two leading monorepo tools solve the same problem (fast builds at scale) with different philosophies.
| Feature | Turborepo | Nx |
|---|---|---|
| Philosophy | Minimal, fast, composable. Does one thing well: task orchestration. | Full platform. Generators, dependency graph visualization, plugins. |
| Configuration | Single turbo.json. Minimal config. | nx.json + project.json per package. More config, more control. |
| Remote caching | Vercel Remote Cache (built-in) or custom. One-line setup. | Nx Cloud or custom. Feature-rich dashboard with analytics. |
| Task graph | Inferred from package.json scripts and dependencies. | Explicit or inferred. Can define custom task relationships. |
| Code generation | None built-in. Use any generator. | Built-in generators for apps, libs, components. Plugin ecosystem. |
| Affected detection | Basic -- hash-based, detects changed packages. | Advanced -- full dependency graph analysis with fine-grained affected detection. |
| Bundle size (dev dep) | ~10MB | ~50-100MB (with plugins) |
| Learning curve | Low -- read turbo.json and you understand the system | Medium -- more concepts (targets, executors, generators) |
| Best for | Teams that want speed and simplicity | Teams that want a full-featured build platform |
Turborepo Setup
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"]
}
}
}
"dependsOn": ["^build"] means: before building this package, build all its dependencies first. The ^ prefix means "upstream dependencies." Turborepo figures out the correct build order from the dependency graph.
Nx Setup
{
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"cache": true
},
"test": {
"dependsOn": ["build"],
"cache": true
}
},
"namedInputs": {
"production": [
"default",
"!{projectRoot}/**/*.spec.ts",
"!{projectRoot}/**/*.test.ts"
]
}
}
Nx additionally gives you nx affected which only runs tasks for packages affected by your current changes:
npx nx affected --target=test --base=main
This is critical for large monorepos. If you changed packages/ui/, only test packages that depend on packages/ui/, not the entire monorepo.
Internal Packages Pattern
The most powerful monorepo pattern: internal packages that are never published to npm but consumed like regular packages.
packages/
ui/
package.json → "name": "@acme/ui"
src/
button.tsx
input.tsx
card.tsx
index.ts → barrel export
tsconfig.json
utils/
package.json → "name": "@acme/utils"
src/
format-date.ts
cn.ts
slugify.ts
index.ts
tsconfig.json
The key trick: each internal package's package.json points its exports directly at the TypeScript source:
{
"name": "@acme/ui",
"private": true,
"exports": {
".": "./src/index.ts"
},
"types": "./src/index.ts"
}
No build step for internal packages. The consuming app's bundler (Next.js, Vite) compiles the TypeScript directly. This means:
- Changes are reflected instantly in dev mode (HMR works across packages)
- No build orchestration needed for development
- Type errors in the package show up immediately in the consuming app
Next.js needs to know which workspace packages contain TypeScript/JSX. Add them to next.config.ts:
const nextConfig = {
transpilePackages: ["@acme/ui", "@acme/utils"],
};This tells Next.js to compile these packages with its own SWC/Babel pipeline instead of expecting pre-built JavaScript.
Package Boundaries
The biggest risk in a monorepo: packages importing internals from other packages, creating hidden dependencies. Enforce boundaries with tooling.
{
"rules": {
"import/no-internal-modules": ["error", {
"allow": [
"@acme/ui",
"@acme/utils",
"@acme/tsconfig"
]
}]
}
}
Or with Nx boundary rules:
{
"rules": {
"@nx/enforce-module-boundaries": ["error", {
"depConstraints": [
{ "sourceTag": "scope:app", "onlyDependOnLibsWithTags": ["scope:shared", "scope:feature"] },
{ "sourceTag": "scope:feature", "onlyDependOnLibsWithTags": ["scope:shared"] },
{ "sourceTag": "scope:shared", "onlyDependOnLibsWithTags": ["scope:shared"] }
]
}]
}
}
This ensures features cannot depend on apps, shared packages cannot depend on features, and the dependency direction is always inward.
Without boundary enforcement, a monorepo quickly becomes a "distributed monolith" -- all the complexity of a monorepo with none of the modularity benefits. Every package imports from every other package, and changing anything requires understanding everything. Enforce boundaries from day one, not after the mess is made.
Dependency Management
Monorepos need careful dependency management to avoid version conflicts and bloat.
Single Version Policy
Use one version of each dependency across the entire monorepo. If apps/web uses React 19.0.0, apps/admin must also use React 19.0.0. This prevents runtime bugs from version mismatches.
pnpm enforces this with pnpm.overrides:
{
"pnpm": {
"overrides": {
"react": "19.0.0",
"react-dom": "19.0.0",
"typescript": "5.7.3"
}
}
}
Hoisting Control
pnpm's strict node_modules structure prevents phantom dependencies (packages that work because they happen to be hoisted but are not in your package.json). This is why pnpm is the preferred package manager for monorepos.
# .npmrc
shamefully-hoist=false
strict-peer-dependencies=true
CI Optimization
Large monorepos can have 30+ packages. Running everything on every PR is wasteful.
Affected-only Builds
# GitHub Actions example
- name: Determine affected packages
run: npx turbo run build --filter="...[HEAD^1]" --dry-run=json > affected.json
- name: Build affected packages
run: npx turbo run build --filter="...[HEAD^1]"
- name: Test affected packages
run: npx turbo run test --filter="...[HEAD^1]"
--filter="...[HEAD^1]" tells Turborepo: only run tasks for packages that changed since the last commit (and their dependents).
Remote Caching
If CI already built @acme/ui with the same source hash, do not rebuild it. Pull the cached output.
npx turbo run build --remote-cache
Turborepo computes a hash of each package's source files, dependencies, and environment variables. If the hash matches a previous build, it downloads the cached output instead of rebuilding. This can cut CI times by 80%+ for incremental changes.
- 1Start with pnpm workspaces + Turborepo for new monorepos -- simplest setup with best DX
- 2Use internal packages with direct TypeScript exports -- no build step in development
- 3Enforce package boundaries with lint rules from day one -- not after the mess
- 4Single version policy for critical dependencies (React, TypeScript) via pnpm overrides
- 5CI runs only affected packages -- use Turborepo filters or Nx affected detection
| What developers do | What they should do |
|---|---|
| Every package has its own build step that runs before dev mode works Build orchestration in dev mode kills DX. If changing a component requires rebuilding a package first, hot reload is broken. Direct TypeScript exports make changes instant. | Internal packages export TypeScript source directly, compiled by the consuming app's bundler |
| No boundary enforcement -- packages import internals from other packages freely Without boundaries, a monorepo becomes a distributed monolith. Refactoring one package requires understanding all others because hidden dependencies are everywhere. | Barrel exports as public API, lint rules preventing deep imports |
| Running all CI tasks for every PR regardless of what changed A monorepo with 20 packages where only one changed should not run 20 build + test cycles. Affected-only CI can reduce build times from 30 minutes to 3 minutes. | Using affected detection to only build and test changed packages and their dependents |