React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

nextjsturborepomonorepopnpmworkspacesshared-uicaching

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=web

The 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 linking apps/web to packages/ui
  • exports field exposing subpath imports like @repo/ui/button
  • transpilePackages letting Next.js compile raw TSX from workspace packages
  • turbo.json task graph with ^build topological ordering
  • Shared TypeScript and ESLint config packages consumed as workspace deps

Deep Dive

How It Works

  • Turborepo reads turbo.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.
  • Turbo hashes inputs (source files, lockfile, env vars listed in 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.
  • 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=docs

Sharing 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 linked

Changesets 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

TypeScript 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, 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.

Alternatives

AlternativeUse WhenDon't Use When
NxYou need generators, graph visualization, and plugin ecosystem for polyglot reposYou want the lightest possible tool with zero config
LernaMaintaining an existing Lerna repoStarting fresh (it's now a thin wrapper around Nx)
Yarn workspaces onlySimple link-only monorepo with no task caching needsYou need remote caching or task graph features
pnpm workspaces onlyYou only need package linking, not task orchestrationYou run many tasks on CI and want caching
Rush (Microsoft)Very large enterprise monorepos with strict policy controlsSmall teams — it has a steep learning curve
Bun workspacesBun-only projects that want the fastest installYou 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.