Next.js in a Turborepo Monorepo
Set up Next.js 15 inside a Turborepo monorepo with shared UI components, shared TypeScript config, and parallel builds across apps and packages.
Recipe
Quick-reference recipe card — copy-paste ready.
# Scaffold a new Turborepo with the default Next.js + library template
npx create-turbo@latest my-monorepo
# Choose "pnpm" when prompted (recommended for workspace protocol support)
cd my-monorepo
pnpm install
# Run all apps in dev mode in parallel
pnpm dev
# Build everything (cached)
pnpm build
# Run a task for a single workspace
pnpm turbo run dev --filter=webThe default template gives you:
my-monorepo/
apps/
web/ # Next.js 15 + React 19 app
docs/ # Second Next.js app (optional)
packages/
ui/ # Shared React component library
eslint-config/ # Shared ESLint config
typescript-config/# Shared tsconfig base files
turbo.json # Pipeline / task graph
pnpm-workspace.yaml # Workspace definitions
package.json # Root scripts + devDependencies
When to reach for this: When you have more than one Next.js app, a shared design system, or shared utilities that should be versioned and built together — and you want fast, cached builds on CI.
Working Example
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"// package.json (root)
\{
"name": "my-monorepo",
"private": true,
"scripts": \{
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
"clean": "turbo run clean && rm -rf node_modules"
\},
"devDependencies": \{
"turbo": "^2.3.0",
"typescript": "^5.6.3",
"prettier": "^3.3.3"
\},
"packageManager": "pnpm@9.12.0",
"engines": \{
"node": ">=20"
\}
\}// turbo.json
\{
"$schema": "https://turbo.build/schema.json",
"ui": "tui",
"tasks": \{
"build": \{
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"],
"env": ["NODE_ENV", "NEXT_PUBLIC_*"]
\},
"dev": \{
"cache": false,
"persistent": true
\},
"lint": \{
"dependsOn": ["^lint"]
\},
"typecheck": \{
"dependsOn": ["^typecheck"]
\},
"clean": \{
"cache": false
\}
\}
\}// apps/web/package.json
\{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": \{
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit"
\},
"dependencies": \{
"@repo/ui": "workspace:*",
"next": "15.1.0",
"react": "19.0.0",
"react-dom": "19.0.0"
\},
"devDependencies": \{
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.6.3"
\}
\}// packages/ui/package.json
\{
"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 . --max-warnings 0",
"typecheck": "tsc --noEmit"
\},
"devDependencies": \{
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/react": "^19.0.0",
"react": "19.0.0",
"typescript": "^5.6.3"
\},
"peerDependencies": \{
"react": "^19.0.0"
\}
\}// packages/ui/src/button.tsx
import type \{ ButtonHTMLAttributes, ReactNode \} from "react";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> \{
children: ReactNode;
variant?: "primary" | "secondary";
\}
export function Button(\{ children, variant = "primary", ...rest \}: ButtonProps) \{
return (
<button data-variant=\{variant\} \{...rest\}>
\{children\}
</button>
);
\}// apps/web/next.config.ts
import type \{ NextConfig \} from "next";
const nextConfig: NextConfig = \{
// Required: Next.js must transpile workspace packages that ship raw TS/TSX
transpilePackages: ["@repo/ui"],
\};
export default nextConfig;// apps/web/app/page.tsx
import \{ Button \} from "@repo/ui/button";
export default function Home() \{
return (
<main>
<h1>Turborepo + Next.js 15</h1>
<Button variant="primary">Click me</Button>
</main>
);
\}What this demonstrates:
workspace:*protocol linkingapps/webtopackages/uiexportsfield exposing subpath imports like@repo/ui/buttontranspilePackagesletting Next.js compile raw TSX from workspace packagesturbo.jsontask graph with^buildtopological ordering- Shared TypeScript and ESLint config packages consumed as workspace deps
Deep Dive
How It Works
- Turborepo reads
turbo.jsonto build a task graph across every workspace. ^buildmeans "build all internal dependencies before this task" — sopackages/uibuilds (or is ready) beforeapps/web.- Turbo hashes inputs (source files, lockfile, env vars listed in
env) and skips tasks whose hash has not changed — this is the local cache. pnpmresolvesworkspace:*to a symlink insidenode_modules, so edits inpackages/uiare visible instantly inapps/web.transpilePackagesinnext.config.tstells Next.js that@repo/uiships source, not built JS, and must go through SWC.- With Turbopack (
next dev --turbopack), hot reload works across package boundaries without extra watchers.
Variations
Adding a second Next.js app:
# Copy apps/web → apps/docs, change name in package.json
cp -r apps/web apps/docs
# Edit apps/docs/package.json: "name": "docs", different port
pnpm install
pnpm turbo run dev --filter=docsSharing Tailwind v4 config across apps:
/* packages/ui/src/styles.css */
@import "tailwindcss";
@theme \{
--color-brand: #2563eb;
\}/* apps/web/app/globals.css */
@import "@repo/ui/styles.css";Running tasks in parallel vs series:
# Parallel (default for independent tasks)
pnpm turbo run lint typecheck
# Series via dependsOn in turbo.json
# "build": \{ "dependsOn": ["^build", "lint"] \}Scoped commands with --filter:
# Only build the web app and its dependencies
pnpm turbo run build --filter=web...
# Only what changed since main
pnpm turbo run build --filter="...[origin/main]"Remote caching with Vercel:
pnpm turbo login
pnpm turbo link
# turbo.json — optional, already on by default when linkedChangesets for versioning public packages:
pnpm add -Dw @changesets/cli
pnpm changeset init
pnpm changeset # record a change
pnpm changeset version # bump versions
pnpm changeset publish # publish to npmTypeScript Notes
// packages/typescript-config/nextjs.json
\{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Next.js",
"extends": "./base.json",
"compilerOptions": \{
"plugins": [\{ "name": "next" \}],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowJs": true,
"jsx": "preserve",
"noEmit": true
\},
"include": ["src", "next-env.d.ts", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
\}// apps/web/tsconfig.json
\{
"extends": "@repo/typescript-config/nextjs.json",
"compilerOptions": \{
"baseUrl": ".",
"paths": \{
"@/*": ["./*"]
\}
\},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
\}For a shared types-only package, create packages/types with "types": "./src/index.ts" and no runtime code — every workspace can then do import type \{ User \} from "@repo/types".
Gotchas
-
workspace:*is a pnpm/yarn feature — npm does not understand the workspace protocol. Fix: use pnpm (recommended by Turborepo), or Yarn 3+/4, or Bun. If you must use npm, replaceworkspace:*with*and rely on npm's built-in workspace linking. -
Turbo cache misses from inconsistent lockfile — if
pnpm-lock.yamlchanges on CI but not locally (or vice versa), every task re-runs. Fix: commit the lockfile, usepnpm install --frozen-lockfileon CI, and keep thepackageManagerfield in rootpackage.jsonpinned. -
Env vars not passing to all tasks — Turborepo hashes env vars you explicitly list. A var not declared in
turbo.jsonenvis stripped from the task, leading to mysterious "undefined" at build time. Fix: add every required var to theenvarray for that task (e.g."env": ["DATABASE_URL", "NEXT_PUBLIC_*"]). -
Hot reload across packages breaks without
transpilePackages— Next.js refuses to compile raw TSX fromnode_modulesby default, so edits to@repo/uieither crash or fail to update. Fix: add the package totranspilePackagesinnext.config.ts. Alternatively, build the package to JS and ship adist/folder. -
Circular dependencies between packages —
@repo/uiimporting from@repo/utilswhich imports from@repo/uiwill silently break Turbo's task graph and cause runtime failures. Fix: runpnpm turbo run build --dryto inspect the graph, and use a layered package design (leaf packages with no internal deps at the bottom). -
exportsfield shadowingmain— once you add"exports"to a package, any subpath not listed becomes unreachable. Fix: list every entry point you need explicitly, including"./package.json"if consumers read it. -
Turbopack and workspace packages — very old Turbopack builds didn't follow symlinks correctly. If dev server shows stale code, kill it,
rm -rf apps/web/.next, and restart.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Nx | You need generators, graph visualization, and plugin ecosystem for polyglot repos | You want the lightest possible tool with zero config |
| Lerna | Maintaining an existing Lerna repo | Starting fresh (it's now a thin wrapper around Nx) |
| Yarn workspaces only | Simple link-only monorepo with no task caching needs | You need remote caching or task graph features |
| pnpm workspaces only | You only need package linking, not task orchestration | You run many tasks on CI and want caching |
| Rush (Microsoft) | Very large enterprise monorepos with strict policy controls | Small teams — it has a steep learning curve |
| Bun workspaces | Bun-only projects that want the fastest install | You need the maturity and ecosystem of pnpm/Turbo |
FAQs
What does workspace:* mean in a package.json dependency?
It tells pnpm (or yarn/bun) to resolve this dependency to the matching workspace package in the monorepo via a symlink, instead of downloading from npm. The * means "any version currently in the workspace."
Why do I need transpilePackages in next.config.ts for @repo/ui?
Next.js ignores TypeScript and JSX in node_modules by default. Because @repo/ui is symlinked into node_modules and ships raw .tsx, you must list it in transpilePackages so Next's SWC compiler processes it.
What is the difference between dependsOn: ["^build"] and dependsOn: ["build"]?
The caret ^ means "build task of my workspace dependencies must run first" (topological). Without the caret, it means "the build task of this same workspace must run first" (task-level dependency within the same package).
How do I run a task only for one app and its dependencies?
Use the filter with a trailing ellipsis: pnpm turbo run build --filter=web.... This includes web plus every workspace it depends on.
How does Turborepo decide whether to use the cache?
It computes a hash of: task inputs (source files), the lockfile, the declared env vars, and the resolved task graph. If the hash matches a previous run, it replays the cached output and logs instead of re-running.
How do I enable remote caching on Vercel?
Run pnpm turbo login then pnpm turbo link. This writes a .turbo/config.json pointing at your Vercel team and uploads cache artifacts on every successful task.
Gotcha: my env var works locally but is undefined during turbo run build. Why?
Turborepo strips env vars not declared in turbo.json's env (or globalEnv) array, because untracked env vars would poison the cache. Add the variable name to the env list for that task.
Gotcha: hot reload stopped working after I added a new export to @repo/ui. Why?
You probably added the file but forgot to list it in the exports field of packages/ui/package.json. Once exports is present, every subpath must be declared explicitly.
TypeScript: how do I share a tsconfig.json base across all apps?
Create packages/typescript-config with files like base.json and nextjs.json, add it as a workspace devDependency, and use "extends": "@repo/typescript-config/nextjs.json" in each app's tsconfig.json.
TypeScript: how do I use workspace paths like @/components/* alongside workspace packages?
Define paths in the consuming app's tsconfig.json, not in the shared base. paths are resolved relative to baseUrl, which must be the app directory — so each Next.js app owns its own paths block while still extending the shared compiler options.
Can I use npm instead of pnpm with Turborepo?
Yes, Turborepo supports npm, yarn, pnpm, and bun. However, npm does not support the workspace:* protocol, so you'll need to use * or explicit versions and lose some guarantees about linking against the local copy.
How do I add a second Next.js app without duplicating config?
Create apps/docs, point it at the shared @repo/typescript-config and @repo/eslint-config, add @repo/ui as a workspace dep, and Turborepo will discover it automatically on the next pnpm install. Use --filter=docs to target it.
Related
- Environment Variables — env var handling across workspaces
- Deployment — deploying monorepo apps to Vercel
- ESLint Setup — sharing ESLint config as a workspace package
- SaaS Starter — full-stack Next.js 15 SaaS stack