Next.js Monorepo with Turborepo and pnpm: Workspaces, Shared Packages, and CI Caching (2026)

Set up a Next.js 16 monorepo with Turborepo and pnpm. Workspace structure, shared UI packages, Vercel remote caching, and CI config that actually scales.

Next.js Monorepo: Turborepo & pnpm (2026)

Updated: June 21, 2026

A Next.js monorepo with Turborepo and pnpm gives you one repo containing multiple Next.js apps plus shared internal packages (UI, config, types), wired together with workspace protocol links and a single Turborepo pipeline that caches build outputs locally and remotely. In practice that means pnpm install hydrates every app from one lockfile, turbo build only rebuilds what changed since the last successful run, and Vercel's remote cache short-circuits CI when a teammate already compiled the same git SHA. I've run this exact setup across three production SaaS codebases, and honestly, it's the only configuration that's held up under real team scale.

  • Turborepo 2.x with pnpm workspaces is the lowest-friction monorepo stack for Next.js 16. No Lerna, no Nx generators, no Bazel.
  • Use workspace:* protocol for internal packages and transpilePackages in next.config.ts so Next.js compiles them through SWC instead of requiring pre-built dist folders.
  • Define a single turbo.json pipeline where build depends on ^build, and declare outputs: [".next/**", "!.next/cache/**"] to make caching deterministic.
  • Enable Vercel Remote Cache with turbo login && turbo link. CI builds drop from 4 minutes to 20 seconds on cache hits.
  • Vercel's monorepo deploy needs Root Directory set per app plus an ignored build step (turbo-ignore) so unrelated commits don't trigger redeploys.
  • Shared UI packages should export source TS/TSX files, not pre-bundled dist, so each app's Next.js compiler can tree-shake and code-split.

Why Turborepo for Next.js monorepos

If you've ever maintained two Next.js apps that share a design system, you've already felt the gravitational pull toward a monorepo. The real question is which tooling carries the least tax. Turborepo, now at version 2.5 and maintained by Vercel, is purpose-built for this. It does task scheduling and caching, and gets out of the way for everything else. No code generators. No project graph DSL. No opinions about your folder layout beyond "put apps in apps/ and shared packages in packages/." For a staff engineer trying to keep the team's mental model small, that minimalism is the feature.

The pairing with pnpm matters too. npm and Yarn Classic both hoist by default, which produces phantom dependencies. Your app imports react indirectly through a workspace package and works locally, then breaks in production because react isn't in its own package.json. pnpm's symlinked node_modules structure makes those mistakes loud at install time. Combine that with Turborepo's content-aware cache (it hashes inputs, not timestamps) and you get a setup where the only way a build breaks deterministically is if the code is actually broken. Read the Turborepo repository structure guide for the canonical reference, and the pnpm workspaces docs for the install-time resolution details.

Setting up the workspace structure

Start with a clean directory. The convention is two top-level folders: apps/ for deployable surfaces (your Next.js applications) and packages/ for everything reusable (UI library, ESLint config, TypeScript config, utilities, database schema). Anything in packages/ is consumed only via the workspace protocol; nothing in there gets deployed directly.

my-monorepo/
├── apps/
│   ├── web/              # marketing site
│   └── dashboard/        # logged-in SaaS app
├── packages/
│   ├── ui/               # shared React components
│   ├── eslint-config/    # shared lint rules
│   ├── tsconfig/         # shared tsconfig presets
│   └── db/               # Drizzle schema + client
├── pnpm-workspace.yaml
├── turbo.json
└── package.json

The pnpm-workspace.yaml file declares where pnpm looks for packages:

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"

The root package.json stays minimal. It only holds dev tooling that runs across the whole repo. Pin packageManager so Corepack hydrates the right pnpm version on CI:

{
  "name": "my-monorepo",
  "private": true,
  "packageManager": "[email protected]",
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "lint": "turbo run lint",
    "typecheck": "turbo run typecheck"
  },
  "devDependencies": {
    "turbo": "^2.5.0",
    "typescript": "^5.7.0"
  }
}

Create each Next.js app with the standard installer, but keep its dependencies scoped to that app. The only thing the root needs is Turbo itself. Inside apps/web/package.json, reference your shared UI package by workspace protocol: "@repo/ui": "workspace:*". pnpm replaces that with a symlink at install time.

Building a shared UI package

Here's where most monorepo tutorials go wrong: they tell you to add a build step to your UI package and ship pre-compiled dist files. Don't. For Next.js consumers, you want to ship the raw TSX and let each app's compiler (SWC, and soon Rust-based Turbopack) tree-shake and transform it. This keeps the package itself dependency-free in CI, gives downstream apps full source maps, and means React Server Component boundaries are respected. A pre-built package can't be a server component because the file extension and the "use client" directive get rewritten away.

Set up packages/ui/package.json like this:

{
  "name": "@repo/ui",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "exports": {
    "./button": "./src/button.tsx",
    "./card": "./src/card.tsx",
    "./styles.css": "./src/styles.css"
  },
  "scripts": {
    "lint": "eslint .",
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": {
    "@types/react": "^19.0.0",
    "react": "^19.0.0",
    "typescript": "^5.7.0"
  },
  "peerDependencies": {
    "react": "^19.0.0"
  }
}

The exports map matters. By listing each component as its own subpath export, you get import paths like import { Button } from "@repo/ui/button", which is more cache-friendly than a barrel index.ts. Barrel files defeat tree-shaking in some bundlers and force the compiler to parse every file in the package just to resolve one import.

Then, in each consuming Next.js app's next.config.ts, tell Next to compile the package on the fly:

// apps/web/next.config.ts
import type { NextConfig } from "next";

const config: NextConfig = {
  transpilePackages: ["@repo/ui"],
};

export default config;

Without transpilePackages, Next.js refuses to process files outside the app directory and you'll get cryptic syntax errors on JSX. I hit this exact bug shipping a design system into two apps simultaneously, and it's the single most common failure mode for first-time monorepo migrations. See our Turbopack default bundler guide for how the new bundler handles workspace resolution.

Configuring the turbo.json pipeline

Turborepo's job is to figure out the dependency graph between tasks and skip anything whose inputs haven't changed. The configuration lives in turbo.json at the repo root:

{
  "$schema": "https://turborepo.com/schema.json",
  "ui": "tui",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"],
      "env": ["NODE_ENV"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {
      "dependsOn": ["^lint"]
    },
    "typecheck": {
      "dependsOn": ["^typecheck"]
    }
  }
}

Three things to internalize here. First, dependsOn: ["^build"] means "build all my workspace dependencies first"; the caret prefix is what makes the graph traversal work. Second, outputs tells Turborepo which files to cache, and excluding .next/cache/** avoids ballooning the cache with the per-build webpack cache. Third, "persistent": true on dev tells Turborepo this task runs forever and shouldn't block downstream tasks. Without it, turbo run dev will hang waiting for it to "finish."

Run pnpm turbo run build --dry=json to see exactly what Turborepo plans to execute and which tasks it expects to hit the cache. This is the debugging tool you'll reach for most often when something doesn't behave the way you expect. The official turbo.json reference documents every option, including the newer inputs field for tightening cache keys.

Vercel Remote Cache and CI

Local caching is nice. Remote caching is the actual reason teams adopt Turborepo. When CI builds a commit, the cache artifact gets uploaded to Vercel's remote cache. When a teammate then builds the same commit locally, or when a second CI job runs (say, preview deploy after a merge), the cache is downloaded and the build skips. I've watched our dashboard app's CI build drop from 4 minutes 30 seconds to 18 seconds on a cache hit. The math compounds across a team of ten engineers.

Enable it in two commands:

pnpm turbo login
pnpm turbo link

For GitHub Actions, you need to wire the same credentials as env vars (because turbo login writes a token to your home directory, which CI doesn't have):

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
      TURBO_TEAM: ${{ vars.TURBO_TEAM }}
    steps:
      - uses: actions/checkout@v4
      - 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 lint typecheck

The TURBO_TOKEN is the team token generated from your Vercel dashboard; TURBO_TEAM is the slug. Don't reuse a personal token. Rotate it and scope it to the team. For self-hosted alternatives, Turborepo supports a custom remote cache server protocol, and there are open-source implementations (turbo-cache, ducktors/turborepo-remote-cache) you can run on S3 if you'd rather not depend on Vercel for cache storage.

Deploying multiple Next.js apps to Vercel

Vercel handles monorepos natively, but you have to configure each app as a separate project. In the Vercel dashboard, create one project per app and set the Root Directory to apps/web, apps/dashboard, etc. The framework preset will auto-detect Next.js. Override the build command to cd ../.. && pnpm turbo run build --filter=web so Turborepo runs from the repo root and only builds the affected app and its dependencies.

The piece most teams miss: the Ignored Build Step. By default Vercel rebuilds every project on every push, even if the commit only touched an unrelated app. Use Turborepo's helper:

npx turbo-ignore web

Set that as the Ignored Build Step for the web project. It exits 1 (skip build) if the commit didn't change apps/web or any of its workspace dependencies, and exits 0 (proceed) otherwise. Combined with remote caching, your preview deploys go from "every PR rebuilds everything" to "only what changed." Our team's Vercel bill dropped about 40% the month we wired this up.

If your apps share environment variables (analytics keys, feature flags, Sentry DSNs), use a Vercel Team-level shared env var and reference it from each project. Don't duplicate values across projects, it's the same drift problem you'd get with multiple repos. For error tracking, see our Next.js Sentry integration guide on configuring source maps per app.

Turborepo vs Nx for Next.js

This is the comparison everyone wants. Nx is the heavyweight option, an opinionated build system with code generators, a typed project graph, executors for every framework, and deep IDE integration. Turborepo is the minimalist option, just task running and caching, full stop. Neither is "better"; they solve different problems.

DimensionTurborepoNx
Setup complexityTwo config filesGenerators + project.json per package
Learning curveHoursDays to weeks
Code generatorsNoneBuilt-in for React, Next.js, NestJS, etc.
Affected commands--filter=...[origin/main]nx affected -t build
Remote cacheVercel (free for OSS) or self-hostedNx Cloud (paid tiers) or self-hosted
Next.js pluginNo plugin needed@nx/next with executors
Best forSmall-to-mid teams, Vercel-deployed appsLarge enterprises, polyglot stacks

If you're a five-engineer team shipping Next.js to Vercel, Turborepo's ceiling is high enough that you'll never outgrow it. If you're a 200-engineer org with Angular, NestJS, React Native, and a Java backend all in the same repo, Nx's structure actually pays for itself. The middle case (20 to 50 engineers, mostly Next.js) usually still lands on Turborepo, because the cognitive overhead of Nx for a homogeneous stack rarely justifies the features.

Common pitfalls and how to avoid them

Let me save you the half-day debugging sessions I've already had.

1. The "Module not found" loop after adding a workspace dependency. You added "@repo/ui": "workspace:*" to apps/web/package.json but didn't run pnpm install. Symlinks aren't created automatically; pnpm needs to update node_modules.

2. CSS from shared packages not loading. If your UI package ships a styles.css, you have to import it from the consuming app's root layout, not from the UI package's source files (CSS imports in node_modules are treated specially by Next.js). Add import "@repo/ui/styles.css" to apps/web/app/layout.tsx.

3. Type errors only on CI. Your local tsc sees workspace symlinks; your CI tsc sees them too, but you forgot transpilePackages so Next.js's build-time type check uses a different resolution algorithm. Always run turbo run typecheck in CI before turbo run build so failures surface in the right job.

4. The cache that won't invalidate. Turborepo hashes file contents and env vars. If you change an env var that's not declared in turbo.json's env array, Turborepo will happily serve a stale cache. Run turbo run build --force to bust it, then add the missing var to the task's env list.

Frequently Asked Questions

Does Turborepo work with pnpm workspaces?

Yes. pnpm is the recommended package manager for Turborepo monorepos. Turborepo reads pnpm-workspace.yaml to discover packages and resolves workspace:* protocol references correctly. npm and Yarn also work, but pnpm's strict symlink layout catches phantom dependency bugs that the others hide.

How do you share components between multiple Next.js apps?

Create a packages/ui workspace package, export your components from its package.json via the exports field, reference it as "@repo/ui": "workspace:*" in each app, and add the package name to transpilePackages in next.config.ts so Next.js's compiler processes the source files.

Can I deploy a Turborepo monorepo to Vercel?

Yes, and Vercel has first-party support since it maintains Turborepo. Create one Vercel project per app, set the Root Directory to apps/<app-name>, override the build command to use turbo run build --filter=<app>, and configure turbo-ignore as the Ignored Build Step so unrelated commits don't trigger rebuilds.

What's the difference between Turborepo and Nx?

Turborepo is a minimal task runner and cache; Nx is a full build system with code generators, executors, and a typed project graph. Pick Turborepo for small-to-mid teams on a homogeneous Next.js stack. Pick Nx for large polyglot organizations where code generation and structural enforcement pay for the added complexity.

Do I need to build shared packages before running Next.js apps?

No, and you shouldn't. Configure your shared packages to export raw TypeScript and TSX source files, then add them to transpilePackages in each Next.js app's config. Next.js will compile them through SWC at build and dev time, preserving server component boundaries and source maps that pre-built dist files lose.

Mei-Lin Wu
About the Author Mei-Lin Wu

Front-end architect at a SaaS. Owns the build system, the design system, and the war stories about both.