Monorepo with Next.js
Introduction to Monorepo Architecture
A monorepo (monolithic repository) is a software development strategy where code for multiple projects is stored in a single repository. For Next.js applications, monorepos enable code sharing, consistent tooling, and atomic changes across multiple apps and packages.
Why Monorepo? Monorepos are ideal for organizations building multiple related applications (web app, mobile app, admin panel) that share common business logic, UI components, and utilities.
Turborepo Overview
Turborepo is a high-performance build system for JavaScript and TypeScript codebases, optimized for monorepos. It provides intelligent caching, parallel execution, and efficient dependency management.
Key Features
- Remote Caching: Share build artifacts across machines and CI/CD
- Incremental Builds: Only rebuild what changed
- Task Pipelines: Define dependencies between tasks
- Parallel Execution: Run tasks across packages simultaneously
- Content-Aware Hashing: Cache based on file content, not timestamps
Setting Up Turborepo
Installation
# Create new monorepo with Next.js
npx create-turbo@latest my-monorepo
# Or add to existing monorepo
npm install turbo --save-dev
Basic Monorepo Structure
my-monorepo/
├── apps/
│ ├── web/ # Main Next.js app
│ │ ├── app/
│ │ ├── package.json
│ │ └── next.config.js
│ ├── admin/ # Admin panel Next.js app
│ │ ├── app/
│ │ ├── package.json
│ │ └── next.config.js
│ └── docs/ # Documentation site
│ ├── app/
│ └── package.json
├── packages/
│ ├── ui/ # Shared UI components
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── config/ # Shared configs
│ │ ├── eslint/
│ │ ├── typescript/
│ │ └── package.json
│ └── utils/ # Shared utilities
│ ├── src/
│ └── package.json
├── package.json # Root package.json
├── turbo.json # Turborepo configuration
└── pnpm-workspace.yaml # Workspace configuration
Workspace Configuration
Using pnpm (Recommended)
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
# Root package.json
{
"name": "my-monorepo",
"private": true,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test",
"clean": "turbo run clean"
},
"devDependencies": {
"turbo": "^2.0.0"
},
"packageManager": "pnpm@8.15.0"
}
Using npm Workspaces
# Root package.json
{
"name": "my-monorepo",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev"
}
}
Tip: pnpm is recommended for monorepos due to its efficient disk space usage and fast installation times. It uses a content-addressable store for packages.
Turborepo Configuration
turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": [".env", ".env.local"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"],
"inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts"]
},
"clean": {
"cache": false
}
}
}
Pipeline Explanation:
^build- Run dependencies' build tasks firstoutputs- Files to cache for future runsinputs- Files that affect task outputcache: false- Disable caching for dev/watch taskspersistent: true- Keep task running (for dev servers)
Creating Shared Packages
Shared UI Components Package
# packages/ui/package.json
{
"name": "@repo/ui",
"version": "0.0.0",
"private": true,
"main": "./src/index.tsx",
"types": "./src/index.tsx",
"scripts": {
"lint": "eslint . --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"typescript": "^5.3.0"
}
}
// packages/ui/src/Button.tsx
import { ReactNode } from 'react';
interface ButtonProps {
children: ReactNode;
onClick?: () => void;
variant?: 'primary' | 'secondary';
}
export function Button({ children, onClick, variant = 'primary' }: ButtonProps) {
return (
<button
onClick={onClick}
className={`btn btn-${variant}`}
>
{children}
</button>
);
}
// packages/ui/src/index.tsx
export { Button } from './Button';
export { Card } from './Card';
export { Input } from './Input';
TypeScript Configuration Package
# packages/config/typescript/package.json
{
"name": "@repo/typescript-config",
"version": "0.0.0",
"private": true,
"main": "index.js",
"files": [
"base.json",
"nextjs.json",
"react-library.json"
]
}
// packages/config/typescript/base.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "ESNext",
"moduleResolution": "bundler",
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true
}
}
// packages/config/typescript/nextjs.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }]
},
"include": ["src", "next-env.d.ts"],
"exclude": ["node_modules"]
}
Using Shared Packages in Apps
Installing Internal Packages
# apps/web/package.json
{
"name": "web",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@repo/ui": "workspace:*",
"@repo/utils": "workspace:*",
"next": "14.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"@repo/eslint-config": "workspace:*",
"typescript": "^5.3.0"
}
}
// apps/web/app/page.tsx
import { Button } from '@repo/ui';
import { formatDate } from '@repo/utils';
export default function HomePage() {
return (
<div>
<h1>Welcome to My App</h1>
<Button variant="primary">Click Me</Button>
<p>Today is {formatDate(new Date())}</p>
</div>
);
}
TypeScript Configuration
// apps/web/tsconfig.json
{
"extends": "@repo/typescript-config/nextjs.json",
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
Warning: Always use workspace:* protocol in package.json dependencies when referencing internal packages. This ensures the local workspace version is used.
Build Pipelines
Development Mode
# Run all apps in dev mode
pnpm dev
# Run specific app
pnpm --filter web dev
# Run multiple specific apps
pnpm --filter web --filter admin dev
Production Build
# Build all apps
pnpm build
# Build with graph visualization
turbo run build --graph
# Build specific app and its dependencies
pnpm --filter web build
Parallel Execution
# Run lint across all packages in parallel
turbo run lint
# Run tests with maximum parallelism
turbo run test --concurrency=10
Remote Caching
Vercel Remote Cache (Free)
# Link to Vercel
npx turbo login
npx turbo link
# Subsequent builds will use remote cache
pnpm build
Custom Remote Cache
# .turborc
{
"teamId": "your-team-id",
"apiUrl": "https://your-cache-server.com"
}
Tip: Remote caching can reduce CI/CD build times by 40-60% by sharing build artifacts across team members and CI machines.
Environment Variables
Shared Environment Variables
# Root .env
DATABASE_URL="postgresql://..."
REDIS_URL="redis://..."
# apps/web/.env.local
NEXT_PUBLIC_API_URL="https://api.example.com"
NEXT_PUBLIC_APP_NAME="Web App"
# apps/admin/.env.local
NEXT_PUBLIC_API_URL="https://api.example.com"
NEXT_PUBLIC_APP_NAME="Admin Panel"
Environment Variable Package
// packages/env/src/index.ts
import { z } from 'zod';
const envSchema = z.object({
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
NODE_ENV: z.enum(['development', 'production', 'test'])
});
export const env = envSchema.parse(process.env);
// Usage in apps
import { env } from '@repo/env';
console.log(env.DATABASE_URL);
Testing in Monorepo
Jest Configuration
// packages/config/jest/jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
transform: {
'^.+\\.ts$': 'ts-jest'
},
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/*.test.ts'
]
};
// apps/web/jest.config.js
const base = require('@repo/jest-config');
module.exports = {
...base,
displayName: 'web',
testEnvironment: 'jsdom'
};
CI/CD with Turborepo
GitHub Actions
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Build
run: pnpm turbo run build
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
- name: Test
run: pnpm turbo run test
- name: Lint
run: pnpm turbo run lint
Exercise: Build a Monorepo E-commerce System
Create a monorepo with:
- Apps:
- Customer-facing store (Next.js)
- Admin dashboard (Next.js)
- API documentation (Next.js)
- Packages:
@repo/ui- Shared components (Button, Card, Modal)@repo/database- Prisma schema and utilities@repo/auth- Authentication logic@repo/config- Shared configs (ESLint, TypeScript)
- Configure Turborepo with proper caching
- Set up GitHub Actions for CI/CD
- Implement remote caching
Bonus: Add a mobile app workspace using React Native with shared business logic.
Best Practices
Dependency Management
- Keep shared dependencies in sync across packages
- Use exact versions for critical dependencies
- Regularly update dependencies with
pnpm update --latest
Code Organization
- Keep packages small and focused
- Use clear naming conventions (
@repo/package-name) - Document package APIs with TypeScript and comments
Performance
- Enable remote caching for team collaboration
- Use
--filterto run tasks only where needed - Configure proper
outputsfor caching - Avoid circular dependencies between packages
Pro Tip: Use turbo run build --dry-run to see what will be cached and what dependencies exist without actually running tasks.