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.
Nextcraft Engineering Team
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
Why Next.js App Router Is Better for SEO Than Pages Router
The App Router isn't just a new file-system convention — it fundamentally changes how search engines crawl and index your Next.js application.
EngineeringServer Components vs Client Components: Making the Right Call
The boundary between Server and Client Components is the most consequential architectural decision you make in a Next.js application. Here's how to draw it correctly.
EngineeringBuilding High-Performance Next.js Applications for Scale
A deep dive into how we utilize App Router and React Server Components to scale our client builds effectively.
Stay Informed.
Join 1,200+ founders and engineers receiving our monthly deep dives on product engineering, design, and growth.