Monorepo Architecture for Next.js Projects
When your Next.js project grows into multiple apps sharing code, a monorepo is the natural solution. Here's how to set one up without the tooling overhead derailing you.
When You Actually Need a Monorepo
A monorepo isn't always the right choice. It's appropriate when:
- You have 2+ Next.js apps that share significant code (a marketing site + a SaaS app + a docs site)
- You maintain a shared component library consumed by multiple projects
- You have a backend package (shared utilities, types, API clients) used across apps
- You want atomic commits across packages ("this PR adds the API endpoint AND updates the frontend to use it")
If you have one Next.js app, a monorepo adds complexity with minimal benefit. Reach for it when the shared code problem is real, not anticipated.
Turborepo: The Practical Choice
Turborepo (from Vercel) is the monorepo build system most Next.js teams reach for. It handles build caching, parallel task execution, and dependency-aware build ordering.
npx create-turbo@latest
The default structure:
my-monorepo/
├── apps/
│ ├── web/ — Marketing site (Next.js)
│ ├── app/ — SaaS product (Next.js)
│ └── docs/ — Documentation site (Next.js)
├── packages/
│ ├── ui/ — Shared component library
│ ├── config/ — Shared ESLint, TypeScript, Tailwind configs
│ └── db/ — Database client and Prisma schema
├── turbo.json
└── package.json — pnpm/npm workspaces config
Setting Up the Workspace
Using pnpm (recommended for monorepos due to efficient disk usage):
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"]
},
"typecheck": {
"dependsOn": ["^typecheck"]
}
}
}
The ^ prefix means "run this task in dependencies first." So build with dependsOn: ["^build"] ensures packages/ui builds before any apps/* that depend on it.
The Shared UI Package
// packages/ui/package.json
{
"name": "@company/ui",
"version": "0.0.1",
"main": "./index.ts",
"exports": {
".": "./index.ts"
},
"devDependencies": {
"typescript": "catalog:",
"react": "catalog:",
"@types/react": "catalog:"
},
"peerDependencies": {
"react": "^19.0.0"
}
}
// packages/ui/index.ts
export { Button } from './components/Button';
export { Input } from './components/Input';
export { Modal } from './components/Modal';
export type { ButtonProps, InputProps, ModalProps } from './types';
Consuming in an app:
// apps/web/package.json
{
"dependencies": {
"@company/ui": "workspace:*"
}
}
// apps/web/app/page.tsx
import { Button } from '@company/ui';
export default function Page() {
return <Button variant="primary">Get Started</Button>;
}
Shared TypeScript and ESLint Configs
Rather than duplicating configuration across apps:
// packages/config/tsconfig/base.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }]
}
}
// apps/web/tsconfig.json
{
"extends": "@company/config/tsconfig/nextjs.json",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
The Shared Database Package
If multiple apps share the same database:
// packages/db/index.ts
export { db } from './client';
export * from '@prisma/client'; // Re-export Prisma types
// packages/db/client.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const db = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = db;
}
Both the marketing site (for newsletter signups) and the SaaS app (for everything) can import from @company/db.
Running Tasks
# Run dev servers for all apps in parallel
pnpm turbo dev
# Run dev for only the web app
pnpm turbo dev --filter=web
# Build all apps (builds packages first due to dependency graph)
pnpm turbo build
# Run tests only for packages that changed since main branch
pnpm turbo test --filter="...[origin/main]"
Turborepo caches task outputs. If you run build and no files changed, subsequent builds are instant — the cached output is restored.
Deployment
Each app in the monorepo deploys independently. On Vercel:
- Create a separate Vercel project for each app in
apps/ - Set the Root Directory to
apps/web(orapps/app, etc.) - Vercel automatically detects Turborepo and optimizes the build
For CI, use Turborepo's remote caching to share build cache across CI runs:
# .github/workflows/ci.yml
- name: Build
run: pnpm turbo build
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
With remote caching, a PR that only changes apps/web won't rebuild apps/docs or packages/ui — only what's affected gets rebuilt. On large monorepos, this can cut CI time by 80%+.
Continue reading
Related articles
Rate Limiting in Next.js: Protecting Your API Routes
How to implement production-grade rate limiting in Next.js — with Middleware-level protection, per-user limits, and distributed rate limiting using Upstash Redis.
EngineeringNext.js Parallel Routes and Intercepting Routes: A Complete Guide
Parallel routes and intercepting routes are among the most powerful App Router primitives. This guide explains what they do, when to use them, and how to avoid the common pitfalls.
EngineeringVercel vs Netlify vs AWS Amplify for Next.js in 2026
A practical comparison of the three most common Next.js hosting platforms — Vercel, Netlify, and AWS Amplify — with real cost and capability trade-offs.
Stay informed
Get our monthly deep dives.
Engineering, design, and growth insights — once a month. No spam.
Browse all resources