Blog/Deep Dives/Monorepo Architecture: Turborepo vs Nx in Practice
POST
August 20, 2025
LAST UPDATEDAugust 20, 2025

Monorepo Architecture: Turborepo vs Nx in Practice

A practical comparison of Turborepo and Nx for monorepo architecture, covering task orchestration, caching, CI/CD, and real-world project structure.

Tags

MonorepoTurborepoNxArchitecture
Monorepo Architecture: Turborepo vs Nx in Practice
8 min read

Monorepo Architecture: Turborepo vs Nx in Practice

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.

TL;DR

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.

Why This Matters

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.

How It Works

Project Structure

Both Turborepo and Nx work with a similar monorepo structure built on package manager workspaces.

bash
# 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
typescript
// 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: Task Orchestration and Caching

Turborepo's configuration lives in turbo.json. You define tasks, their dependencies, and their outputs. Turborepo handles the rest.

javascript
// 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.

bash
# 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=api

Nx provides everything Turborepo does plus code generators, dependency graph visualization, module boundary enforcement, and framework-specific plugins.

javascript
// 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"
}
bash
# 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=5

Nx'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.

Shared Package Configuration

One of the biggest benefits of a monorepo is sharing configuration. Here is how to set up shared TypeScript and ESLint configurations.

typescript
// 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"]
}

Internal Packages

Shared packages in a monorepo can be consumed without publishing to npm. The key is proper package.json configuration with exports and workspace references.

typescript
// 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>;
typescript
// 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 });
}

Practical Implementation

CI/CD Configuration

Monorepo CI pipelines should build and test only affected packages. Both Turborepo and Nx provide mechanisms for this.

bash
# 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:

bash
# 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 everyone

Dependency Management

Managing dependencies across packages requires discipline. Here are the key patterns:

typescript
// 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.

Turborepo vs Nx Feature Comparison

FeatureTurborepoNx
Task orchestrationYesYes
Local cachingYesYes
Remote cachingVercel (free tier available)Nx Cloud (free tier available)
Dependency graphImplicit from package.jsonExplicit, with visualization
Affected detectionVia --filter flagBuilt-in nx affected
Code generatorsNoYes, extensive
Module boundariesNoYes, via @nx/enforce-module-boundaries
Framework pluginsNoYes (React, Angular, Node, etc.)
Configurationturbo.json (minimal)nx.json + project.json (more detailed)
Learning curveLowMedium
Standalone usageRequires package manager workspacesCan work without workspaces

When to Start with Workspaces Only

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.

typescript
// 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.

Common Pitfalls

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.

When to Use (and When Not To)

Use a monorepo when:

  • Multiple applications share significant code (types, validation, components)
  • A single team owns multiple related projects that evolve together
  • You want atomic commits that change shared code and all its consumers simultaneously
  • Coordinating releases across multiple repositories has become a bottleneck

Avoid a monorepo when:

  • Projects are truly independent with different teams, languages, and release cycles
  • Your repository would exceed your Git hosting provider's size limits
  • Your team does not have the tooling expertise to maintain monorepo infrastructure
  • You are using it purely because it is trendy rather than because it solves a real problem

Choose Turborepo when:

  • You want minimal configuration and a fast start
  • Your monorepo is small to medium-sized
  • You are already deploying to Vercel and want seamless remote caching
  • You need task orchestration and caching but not code generation or module boundaries

Choose Nx when:

  • Your monorepo is large with many interdependent packages
  • You need code generators to scaffold new packages consistently
  • You want module boundary enforcement to prevent architectural erosion
  • You need framework-specific optimizations (Angular, React, Node plugins)
  • You want rich dependency graph visualization for debugging build issues

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.

FAQ

What is a monorepo and when should you use one?

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.

What is the difference between Turborepo and Nx?

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.

How does remote caching work in 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.

Can you migrate from Turborepo to Nx or vice versa?

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.

Do you need Turborepo or Nx for a monorepo?

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

Need help with a project?

Let's Build It

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.

SH

Article Author

Sadam Hussain

Senior Full Stack Developer

Senior Full Stack Developer with over 7 years of experience building React, Next.js, Node.js, TypeScript, and AI-powered web platforms.

Related Articles

How to Design API Contracts Between Micro-Frontends and BFFs
Mar 21, 20266 min read
Micro-Frontends
BFF
API Design

How to Design API Contracts Between Micro-Frontends and BFFs

Learn how to design stable API contracts between Micro-Frontends and Backend-for-Frontend layers with versioning, ownership boundaries, error handling, and schema governance.

Next.js BFF Architecture
Mar 21, 20261 min read
Next.js
BFF
Architecture

Next.js BFF Architecture

An architectural deep dive into using Next.js as a Backend-for-Frontend, including route handlers, server components, auth boundaries, caching, and service orchestration.

Next.js Cache Components and PPR in Real Apps
Mar 21, 20266 min read
Next.js
Performance
Caching

Next.js Cache Components and PPR in Real Apps

A practical guide to using Next.js Cache Components and Partial Prerendering in real applications, with tradeoffs, cache strategy, and freshness considerations.