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.
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) 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
envarray. 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_modulesfor 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
enginesand.nvmrc. Cache hits depend on consistent toolchains. - Use
turbo pruneto 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 andpackages/*as private libraries. Resist the urge to publish internal packages to npm. - Add a
lint:fixtask 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.
Related articles
- CI/CD CI/CD Monorepo Strategies That Scale
Learn how to design CI/CD pipelines for monorepos using affected detection, build graphs, and caching to keep builds fast as the repo grows.
- Go Go Build Tags Explained
Use Go build tags to include or exclude files per OS, architecture, or custom condition. Learn the new //go:build syntax, common patterns, and how tags interact with the test runner.
- Next.js Next.js Caching Strategies Explained
Walk through the four caching layers in Next.js App Router and learn how to choose static, ISR, dynamic, or per-request fetch caching.
- Next.js Next.js Data Fetching Patterns: A Practical Guide
Compare the main Next.js data fetching strategies and learn when to use server components, route handlers, SWR, or static generation in your apps.