Loading…
Loading…
A practical comparison of Turborepo and Nx for monorepo architecture, covering task orchestration, caching, CI/CD, and real-world project structure.
Tags
A monorepo puts multiple projects in one repository so they can share code, tooling, and release processes. Turborepo and Nx are the two leading tools for making monorepos practical at scale. Turborepo gives you fast, cached task execution with minimal setup. Nx gives you a comprehensive build system with code generation, dependency graph analysis, and framework-specific plugins. The right choice depends on how much structure and tooling you need beyond basic task caching.
For most JavaScript and TypeScript monorepos, start with Turborepo. It is simpler to adopt, requires minimal configuration, and handles caching and task orchestration well. Move to Nx when you need code generators, affected-only testing across large dependency graphs, module boundary enforcement, or framework-specific plugins. Both are production-ready and well-maintained.
As projects grow, code sharing becomes a practical necessity. Your web app, mobile app, and API server probably share validation logic, type definitions, API contracts, and utility functions. Without a monorepo, you publish these shared pieces as npm packages, manage versions across repositories, and coordinate releases manually. This works but creates friction that slows teams down.
A monorepo eliminates that friction by keeping everything in one repository. Change a shared validation schema and immediately see if it breaks the web app, the API, and the mobile app. Run tests for all affected projects in one CI pipeline. Share TypeScript configurations, ESLint rules, and build tooling across every project.
But monorepos introduce their own problem: build performance. When you have twenty packages and five applications, running npm run build for everything on every commit is wasteful. Most commits only affect a few packages. Turborepo and Nx solve this by understanding the dependency graph, caching build outputs, and running only what has changed.
Both Turborepo and Nx work with a similar monorepo structure built on package manager workspaces.
# Typical monorepo structure
my-monorepo/
├── apps/
│ ├── web/ # Next.js web application
│ │ ├── package.json
│ │ └── src/
│ ├── api/ # Express/Fastify API server
│ │ ├── package.json
│ │ └── src/
│ └── docs/ # Documentation site
│ ├── package.json
│ └── src/
├── packages/
│ ├── ui/ # Shared React component library
│ │ ├── package.json
│ │ └── src/
│ ├── db/ # Shared database schema and client
│ │ ├── package.json
│ │ └── src/
│ ├── validators/ # Shared Zod validation schemas
│ │ ├── package.json
│ │ └── src/
│ ├── config-eslint/ # Shared ESLint configuration
│ │ └── package.json
│ └── config-typescript/ # Shared TypeScript configuration
│ └── package.json
├── package.json # Root workspace configuration
├── pnpm-workspace.yaml # Workspace definition (pnpm)
└── turbo.json # or nx.json// pnpm-workspace.yaml
// packages:
// - "apps/*"
// - "packages/*"
// Root package.json
{
"name": "my-monorepo",
"private": true,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test",
"typecheck": "turbo run typecheck"
},
"devDependencies": {
"turbo": "^2.0.0"
}
}Turborepo's configuration lives in turbo.json. You define tasks, their dependencies, and their outputs. Turborepo handles the rest.
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**", "build/**"],
"env": ["NODE_ENV", "DATABASE_URL"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"typecheck": {
"dependsOn": ["^build"]
}
}
}The ^build syntax means "run the build task in all dependencies first." When you run turbo run build for the web app, Turborepo automatically builds ui, db, and validators first (because web depends on them), then builds web. If ui has not changed since the last build, Turborepo skips it entirely and replays the cached output.
# Run build for all packages
turbo run build
# Run build for a specific package and its dependencies
turbo run build --filter=web
# Run build for packages affected by changes since main
turbo run build --filter=...[main]
# Run dev servers for web and api in parallel
turbo run dev --filter=web --filter=apiNx provides everything Turborepo does plus code generators, dependency graph visualization, module boundary enforcement, and framework-specific plugins.
// nx.json
{
"$schema": "https://nx.dev/reference/nx-json",
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"sharedGlobals": ["{workspaceRoot}/.github/workflows/**/*"],
"production": [
"default",
"!{projectRoot}/**/*.spec.ts",
"!{projectRoot}/**/*.test.ts"
]
},
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"inputs": ["production", "^production"],
"cache": true
},
"test": {
"inputs": ["default", "^production"],
"cache": true
},
"lint": {
"inputs": ["default", "{workspaceRoot}/.eslintrc.json"],
"cache": true
}
},
"defaultBase": "main"
}# Run build for affected projects only
npx nx affected -t build
# Visualize the dependency graph
npx nx graph
# Generate a new library
npx nx generate @nx/react:library shared-utils --directory=packages/shared-utils
# Run tasks in parallel with a concurrency limit
npx nx run-many -t build test lint --parallel=5Nx's affected command is particularly powerful. It uses the dependency graph to determine exactly which projects are affected by the current changes and runs tasks only for those projects. This can reduce CI time dramatically in large monorepos.
One of the biggest benefits of a monorepo is sharing configuration. Here is how to set up shared TypeScript and ESLint configurations.
// packages/config-typescript/base.json
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true
}
}
// packages/config-typescript/nextjs.json
{
"extends": "./base.json",
"compilerOptions": {
"lib": ["dom", "dom.iterable", "ES2022"],
"jsx": "preserve",
"module": "ESNext",
"plugins": [{ "name": "next" }]
}
}
// apps/web/tsconfig.json
{
"extends": "@myorg/config-typescript/nextjs.json",
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "next-env.d.ts"],
"exclude": ["node_modules"]
}Shared packages in a monorepo can be consumed without publishing to npm. The key is proper package.json configuration with exports and workspace references.
// packages/validators/package.json
{
"name": "@myorg/validators",
"version": "0.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
}
}
// packages/validators/src/index.ts
import { z } from 'zod';
export const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
role: z.enum(['admin', 'editor', 'viewer']).default('viewer'),
});
export const updateUserSchema = createUserSchema.partial().omit({ email: true });
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;// apps/web/src/app/api/users/route.ts - consuming the shared package
import { createUserSchema } from '@myorg/validators';
export async function POST(request: Request) {
const body = await request.json();
const result = createUserSchema.safeParse(body);
if (!result.success) {
return Response.json({ errors: result.error.flatten() }, { status: 400 });
}
const user = await createUser(result.data);
return Response.json(user, { status: 201 });
}Monorepo CI pipelines should build and test only affected packages. Both Turborepo and Nx provide mechanisms for this.
# GitHub Actions with Turborepo
# .github/workflows/ci.yml
# name: CI
# on: [push, pull_request]
# jobs:
# build:
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# with:
# fetch-depth: 0 # Needed for change detection
# - uses: pnpm/action-setup@v4
# - uses: actions/setup-node@v4
# with:
# node-version: 20
# cache: 'pnpm'
# - run: pnpm install --frozen-lockfile
# - run: pnpm turbo run build test lint --filter=...[origin/main]For Turborepo, enable remote caching to share build artifacts between CI runs and developer machines:
# Link to Vercel remote cache
npx turbo login
npx turbo link
# Now builds are cached across all CI runs and developers
# A package built by one developer is instantly available to everyoneManaging dependencies across packages requires discipline. Here are the key patterns:
// Root package.json - shared dev dependencies
{
"devDependencies": {
"turbo": "^2.0.0",
"typescript": "^5.4.0", // Single TypeScript version
"prettier": "^3.2.0" // Single Prettier version
}
}
// Individual packages reference workspace dependencies
// apps/web/package.json
{
"dependencies": {
"@myorg/ui": "workspace:*",
"@myorg/validators": "workspace:*",
"@myorg/db": "workspace:*",
"next": "^15.0.0",
"react": "^19.0.0"
}
}Use your package manager's workspace protocol (workspace:* for pnpm) to ensure internal packages always resolve to the local version rather than a published version.
| Feature | Turborepo | Nx |
|---|---|---|
| Task orchestration | Yes | Yes |
| Local caching | Yes | Yes |
| Remote caching | Vercel (free tier available) | Nx Cloud (free tier available) |
| Dependency graph | Implicit from package.json | Explicit, with visualization |
| Affected detection | Via --filter flag | Built-in nx affected |
| Code generators | No | Yes, extensive |
| Module boundaries | No | Yes, via @nx/enforce-module-boundaries |
| Framework plugins | No | Yes (React, Angular, Node, etc.) |
| Configuration | turbo.json (minimal) | nx.json + project.json (more detailed) |
| Learning curve | Low | Medium |
| Standalone usage | Requires package manager workspaces | Can work without workspaces |
For small monorepos with two to three packages, you may not need Turborepo or Nx at all. Plain package manager workspaces provide dependency linking, and simple npm scripts handle builds.
// A minimal monorepo with just pnpm workspaces
// pnpm-workspace.yaml
// packages:
// - "packages/*"
// - "apps/*"
// Root package.json
{
"scripts": {
"build": "pnpm -r run build",
"dev": "pnpm -r --parallel run dev",
"lint": "pnpm -r run lint"
}
}Add Turborepo or Nx when builds take too long without caching, when you need affected-only testing, or when coordination across many packages becomes manual and error-prone.
Starting with a monorepo before you need one. If you have one application and no shared packages, a monorepo adds complexity with no benefit. Start with a monorepo when you have a clear need for code sharing between two or more projects.
Not defining clear package boundaries. A monorepo without clear boundaries between packages devolves into a tangled mess. Each package should have a clear purpose, a defined public API (via exports in package.json), and explicit dependencies. Nx's module boundary enforcement helps here.
Circular dependencies between packages. If package A depends on package B and package B depends on package A, your build system cannot determine the correct build order. Design packages in layers: core utilities at the bottom, domain logic in the middle, applications at the top.
Duplicating dependencies across packages. Multiple packages installing different versions of the same dependency (like React) causes bundle bloat and subtle bugs. Hoist shared dependencies to the root or use your package manager's deduplication features.
Ignoring build output caching in CI. Without remote caching, every CI run rebuilds everything from scratch. Enable remote caching early. The time savings compound quickly as your monorepo grows.
Use a monorepo when:
Avoid a monorepo when:
Choose Turborepo when:
Choose Nx when:
Both tools are actively maintained and production-ready. The JavaScript ecosystem is well-served by having both a simple option (Turborepo) and a comprehensive option (Nx) for monorepo management.
A monorepo is a single repository containing multiple projects, packages, or applications. Use one when multiple projects share code, need coordinated changes, or when a single team owns related services that evolve together. The primary benefit is eliminating the friction of publishing and versioning shared packages across multiple repositories.
Turborepo focuses on build orchestration and caching with minimal configuration, making it easy to adopt in existing workspaces. Nx provides a full-featured build system with code generators, dependency graph visualization, module boundary enforcement, and framework-specific plugins. Turborepo is simpler to learn and configure; Nx is more powerful for complex monorepos.
Remote caching stores build outputs in a shared cloud cache so that when one developer or CI runner builds a package, others can download the cached output instead of rebuilding. Turborepo uses Vercel's remote cache, and Nx uses Nx Cloud. Both offer free tiers. The effect is that a build only happens once across your entire team and CI infrastructure.
Yes, migration in either direction is possible because both tools work on top of standard package manager workspaces (npm, yarn, pnpm). The migration primarily involves converting task configuration files (turbo.json to nx.json or vice versa) and adapting CI pipeline scripts. Your source code and package structure remain unchanged.
No, you can run a monorepo with just npm, yarn, or pnpm workspaces. These provide dependency linking and basic script running. Turborepo and Nx add task orchestration, intelligent caching, affected-only execution, and remote caching on top of workspaces. For small monorepos with two to three packages, plain workspaces may be entirely sufficient.
Collaboration
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI: let's bring your vision to life.
Learn how to design stable API contracts between Micro-Frontends and Backend-for-Frontend layers with versioning, ownership boundaries, error handling, and schema governance.
An architectural deep dive into using Next.js as a Backend-for-Frontend, including route handlers, server components, auth boundaries, caching, and service orchestration.
A practical guide to using Next.js Cache Components and Partial Prerendering in real applications, with tradeoffs, cache strategy, and freshness considerations.