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.
Search across all documentation pages
Set up Next.js 15 inside a Turborepo monorepo with shared UI components, shared TypeScript config, and parallel builds across apps and packages.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
# 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 linking apps/web to packages/uiexports field exposing subpath imports like @repo/ui/buttontranspilePackages letting Next.js compile raw TSX from workspace packagesturbo.json task graph with ^build topological orderingturbo.json to build a task graph across every workspace.^build means "build all internal dependencies before this task" - so packages/ui builds (or is ready) before apps/web.env) and skips tasks whose hash has not changed - this is the local cache.pnpm resolves workspace:* to a symlink inside node_modules, so edits in packages/ui are visible instantly in apps/web.transpilePackages in next.config.ts tells Next.js that @repo/ui ships source, not built JS, and must go through SWC.next dev --turbopack), hot reload works across package boundaries without extra watchers.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 npm// 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".
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, replace workspace:* with * and rely on npm's built-in workspace linking.
Turbo cache misses from inconsistent lockfile - if pnpm-lock.yaml changes on CI but not locally (or vice versa), every task re-runs. Fix: commit the lockfile, use pnpm install --frozen-lockfile on CI, and keep the packageManager field in root package.json pinned.
Env vars not passing to all tasks - Turborepo hashes env vars you explicitly list. A var not declared in turbo.json env is stripped from the task, leading to mysterious "undefined" at build time. Fix: add every required var to the env array for that task (e.g. "env": ["DATABASE_URL", "NEXT_PUBLIC_*"]).
Hot reload across packages breaks without transpilePackages - Next.js refuses to compile raw TSX from node_modules by default, so edits to @repo/ui either crash or fail to update. Fix: add the package to transpilePackages in next.config.ts. Alternatively, build the package to JS and ship a dist/ folder.
Circular dependencies between packages - @repo/ui importing from @repo/utils which imports from @repo/ui will silently break Turbo's task graph and cause runtime failures. Fix: run pnpm turbo run build --dry to inspect the graph, and use a layered package design (leaf packages with no internal deps at the bottom).
exports field shadowing main - 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.
| 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 |
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."
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.
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).
Use the filter with a trailing ellipsis: pnpm turbo run build --filter=web.... This includes web plus every workspace it depends on.
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.
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.
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.
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.
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.
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.
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.
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.
Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥