Skip to content
C Codeloom
Next.js

Building a Next.js Monorepo with Turborepo

Set up a fast, cache-friendly Next.js monorepo using Turborepo. Share UI, config, and types between apps without sacrificing build performance.

·4 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • How Turborepo caches and orchestrates tasks
  • Workspace layout for shared UI and configs
  • Configuring remote caching for CI
  • Avoiding the dependency hell of separate repos
  • Migrating an existing Next.js app into a monorepo

Prerequisites

  • Familiar with HTTP and JS
  • Comfort with package.json and npm scripts

What and Why

When one Next.js app turns into three (web, admin, marketing site), you face a choice: maintain three repos with duplicated UI and config, or put them all in one repo and share code. Monorepos used to be slow because every task ran across every package on every commit. Turborepo fixes that with a content-addressable cache and a task graph that only runs what changed.

The payoff is concrete: one place for the design system, one place for ESLint rules, and CI runs that finish in seconds instead of minutes.

Mental Model

A Turborepo monorepo has two folders that matter: apps/ for deployable things and packages/ for shared libraries. A root turbo.json describes tasks and their dependencies. When you run turbo build, Turborepo walks the dependency graph, hashes inputs, and either runs the task or restores its output from the cache.

The cache key is everything: source files, environment variables, dependencies, and the task itself. Change anything and Turborepo knows.

Hands-on Example

A typical layout looks like this:

apps/web ----+
           |
apps/admin --+--> packages/ui ---> packages/tsconfig
           |        |
apps/site ---+        +-------> packages/eslint-config

turbo build:
1. build packages/tsconfig (no deps)
2. build packages/ui, eslint-config (in parallel)
3. build apps/web, admin, site (in parallel)
Monorepo task graph

Bootstrap the workspace with npm workspaces in the root package.json:

{
  "name": "company",
  "private": true,
  "workspaces": ["apps/*", "packages/*"],
  "devDependencies": { "turbo": "^2.0.0" },
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "lint": "turbo run lint"
  }
}

Then a turbo.json describing the tasks:

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "lint": { "outputs": [] },
    "dev": { "cache": false, "persistent": true }
  }
}

The ^build syntax means “run build in this package’s dependencies first”. A shared UI package looks like a normal npm package:

// packages/ui/package.json
{
  "name": "@company/ui",
  "main": "./src/index.ts",
  "types": "./src/index.ts"
}

Inside an app, you depend on it like any other package:

// apps/web/package.json
{ "dependencies": { "@company/ui": "*", "next": "^15.0.0" } }

Add a Next.js transpilePackages entry so internal sources are compiled:

// apps/web/next.config.js
module.exports = { transpilePackages: ["@company/ui"] };

Now npm run build at the root builds every app, in dependency order, with caching. CI gets a massive speedup once you enable remote cache via turbo login and turbo link.

Common Pitfalls

  • Listing every output file in turbo.json. If you forget one, Turborepo will hand back a partial build from cache and you will chase a phantom bug.
  • Reading env vars at build time without listing them in the task’s env array. Turborepo will not invalidate the cache when they change.
  • Symlinking shared packages with relative paths instead of using workspace protocol. Builds break the first time someone clones on Windows.
  • Putting tests next to source without scoping inputs. Editing a test invalidates the production build cache unnecessarily.
  • Sharing a single node_modules for everything via npm hoisting and then being surprised when Next.js can’t find a transitive dep.

Practical Tips

  • Start small. Move ESLint config and TS config into packages first; they pay for themselves immediately.
  • Pin Node and npm versions via engines and .nvmrc. Cache hits depend on consistent toolchains.
  • Use turbo prune to produce a slim Docker context per app. Image sizes drop dramatically.
  • Set up remote cache on day one. The first CI cache hit feels like magic.
  • Treat apps/* as deployment units and packages/* as private libraries. Resist the urge to publish internal packages to npm.
  • Add a lint:fix task that depends on ^lint:fix. Auto-formatting at the monorepo level keeps PRs clean.

Wrap-up

Turborepo turns a Next.js monorepo from a theoretical idea into a productive workflow. The combination of workspaces, a task graph, and a content-addressable cache means shared code does not cost build time, and CI does not punish you for collocating apps. Once you have one Next.js app, the second is the right moment to set this up. By the third app, you will wonder how teams ever managed without it.