The Problem
Managing a B2B2C multi-tenant platform with separate codebases for admin dashboard, consumer app, and marketing site led to:
- Duplicated code: Same components copied across repos
- Version drift: Different dependency versions causing bugs
- Slow iteration: Changes required updating multiple repos
- Inconsistent UX: Design system divergence
Solution: Migrate to a Turbo monorepo with shared packages.
Turbo monorepos excel at managing multiple Next.js apps with shared code. The caching and parallel execution dramatically improve build times as your platform grows.
Monorepo Structure
my-saas/
├── apps/
│ ├── web/ # Consumer-facing app (Next.js)
│ ├── admin/ # Admin dashboard (Next.js)
│ ├── marketing/ # Marketing site (Next.js)
│ └── api/ # Backend API (Node.js/Express)
├── packages/
│ ├── ui/ # Shared React components
│ ├── config/ # Shared configs (ESLint, TypeScript, Tailwind)
│ ├── database/ # Database schema & client
│ ├── auth/ # Authentication logic
│ ├── utils/ # Shared utilities
│ └── types/ # Shared TypeScript types
├── package.json
├── turbo.json
└── pnpm-workspace.yaml
Initial Setup
Install Turbo
# Create new monorepo
npx create-turbo@latest
# Or add to existing project
pnpm add turbo -D -w
Configure Workspace
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
// package.json (root)
{
"name": "my-saas-monorepo",
"private": true,
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test",
"clean": "turbo run clean && rm -rf node_modules"
},
"devDependencies": {
"turbo": "^1.11.0",
"@types/node": "^20.0.0",
"typescript": "^5.3.0"
},
"engines": {
"node": ">=18.0.0",
"pnpm": ">=8.0.0"
},
"packageManager": "pnpm@8.15.0"
}
Turbo Configuration
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"]
},
"type-check": {
"dependsOn": ["^type-check"]
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"deploy": {
"dependsOn": ["build", "test", "lint"],
"outputs": []
},
"clean": {
"cache": false
}
}
}
Shared Packages
1. UI Component Library
// packages/ui/package.json
{
"name": "@my-saas/ui",
"version": "0.0.0",
"main": "./index.tsx",
"types": "./index.tsx",
"license": "MIT",
"scripts": {
"lint": "eslint .",
"type-check": "tsc --noEmit"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"@my-saas/config": "workspace:*",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"typescript": "^5.3.0"
}
}
// packages/ui/index.tsx
export { Button } from './components/Button';
export { Input } from './components/Input';
export { Modal } from './components/Modal';
export { Card } from './components/Card';
export { Table } from './components/Table';
// ... more exports
// packages/ui/components/Button.tsx
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
}
export function Button({
children,
variant = 'primary',
size = 'md',
loading = false,
className = '',
disabled,
...props
}: ButtonProps) {
const baseStyles = 'rounded-lg font-medium transition-colors';
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-600 text-white hover:bg-gray-700',
outline: 'border-2 border-blue-600 text-blue-600 hover:bg-blue-50',
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
};
return (
<button
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
disabled={disabled || loading}
{...props}
>
{loading ? 'Loading...' : children}
</button>
);
}
2. Shared TypeScript Config
// packages/config/tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowJs": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"incremental": true,
"isolatedModules": true
},
"exclude": ["node_modules"]
}
// apps/web/tsconfig.json
{
"extends": "@my-saas/config/tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"plugins": [{ "name": "next" }]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
3. Database Package
// packages/database/index.ts
export { db } from './client';
export * from './schema';
export type * from './types';
// packages/database/client.ts
import { createClient } from '@supabase/supabase-js';
import type { Database } from './types';
export const db = createClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
// packages/database/schema.ts
import { db } from './client';
export const users = {
async getById(id: string) {
const { data, error } = await db
.from('users')
.select('*')
.eq('id', id)
.single();
if (error) throw error;
return data;
},
async create(user: InsertUser) {
const { data, error } = await db.from('users').insert(user).select().single();
if (error) throw error;
return data;
},
async update(id: string, updates: Partial<User>) {
const { data, error } = await db
.from('users')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw error;
return data;
},
};
export const organizations = {
// ... organization queries
};
4. Authentication Package
// packages/auth/index.ts
export { authConfig, auth } from './config';
export { getSession, requireAuth } from './helpers';
export type { Session, User } from './types';
// packages/auth/config.ts
import NextAuth from 'next-auth';
import { SupabaseAdapter } from '@auth/supabase-adapter';
import GoogleProvider from 'next-auth/providers/google';
import { db } from '@my-saas/database';
export const authConfig = {
adapter: SupabaseAdapter({
url: process.env.NEXT_PUBLIC_SUPABASE_URL!,
secret: process.env.SUPABASE_SERVICE_ROLE_KEY!,
}),
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
callbacks: {
async session({ session, user }) {
session.user.id = user.id;
return session;
},
},
};
export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);
App Configuration
Three separate repositories:
- Duplicate components across repos
- Different package versions
- Manual dependency syncing
- Separate CI/CD pipelines
Single monorepo:
- Shared component library
- Single source of truth for deps
- Automatic internal package linking
- Unified CI/CD with parallel builds
Consumer App (Next.js)
// apps/web/package.json
{
"name": "@my-saas/web",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"next": "^14.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"@my-saas/ui": "workspace:*",
"@my-saas/database": "workspace:*",
"@my-saas/auth": "workspace:*",
"@my-saas/utils": "workspace:*"
},
"devDependencies": {
"@my-saas/config": "workspace:*",
"typescript": "^5.3.0"
}
}
// apps/web/next.config.js
const { withTurbo } = require('turbo');
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ['@my-saas/ui', '@my-saas/database', '@my-saas/auth'],
reactStrictMode: true,
};
module.exports = withTurbo(nextConfig);
Admin Dashboard
// apps/admin/package.json
{
"name": "@my-saas/admin",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "next dev --port 3001",
"build": "next build",
"start": "next start --port 3001",
"lint": "next lint"
},
"dependencies": {
"next": "^14.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"@my-saas/ui": "workspace:*",
"@my-saas/database": "workspace:*",
"@my-saas/auth": "workspace:*"
}
}
Development Workflow
Running All Apps
# Run all apps in development mode
pnpm dev
# Run specific app
pnpm dev --filter=@my-saas/web
# Run multiple apps
pnpm dev --filter=@my-saas/web --filter=@my-saas/admin
Building for Production
# Build all apps
pnpm build
# Build with cache
turbo run build
# Build specific app
turbo run build --filter=@my-saas/web
Type Checking
# Type check all packages
pnpm type-check
# Type check specific package
pnpm type-check --filter=@my-saas/ui
Environment Variables
Shared Environment Variables
# .env (root)
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=xxx
SUPABASE_SERVICE_ROLE_KEY=xxx
App-Specific Variables
# apps/web/.env.local
NEXT_PUBLIC_APP_NAME=Consumer App
NEXT_PUBLIC_API_URL=https://api.myapp.com
# apps/admin/.env.local
NEXT_PUBLIC_APP_NAME=Admin Dashboard
NEXT_PUBLIC_API_URL=https://api-admin.myapp.com
Loading Environment Variables
// packages/config/env.ts
import { z } from 'zod';
const envSchema = z.object({
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
NODE_ENV: z.enum(['development', 'test', 'production']),
});
export const env = envSchema.parse(process.env);
Caching Strategy
Local Caching
Turbo automatically caches build outputs locally in .turbo/cache/.
// turbo.json
{
"pipeline": {
"build": {
"outputs": [".next/**", "!.next/cache/**", "dist/**"],
"cache": true
}
}
}
Remote Caching (Vercel)
# Link to Vercel for remote caching
pnpm dlx turbo login
pnpm dlx turbo link
// turbo.json
{
"remoteCache": {
"enabled": true
}
}
Remote caching with Vercel reduced CI build times from 15 minutes to 2 minutes by sharing cache across team members and CI environments.
Deployment
Vercel Monorepo Setup
// vercel.json (root)
{
"buildCommand": "turbo run build --filter=@my-saas/web",
"outputDirectory": "apps/web/.next",
"installCommand": "pnpm install"
}
Separate Deployments per App
// apps/web/vercel.json
{
"buildCommand": "cd ../.. && turbo run build --filter=@my-saas/web",
"outputDirectory": ".next",
"installCommand": "pnpm install --frozen-lockfile"
}
GitHub Actions CI/CD
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Type check
run: pnpm type-check
- name: Build
run: pnpm build
- name: Test
run: pnpm test
Dependency Management
Adding Dependencies
# Add to root (dev dependencies)
pnpm add -D -w prettier
# Add to specific package
pnpm add react-query --filter=@my-saas/web
# Add to all apps
pnpm add -r lodash --filter=./apps/*
Updating Dependencies
# Update all dependencies
pnpm update -r
# Update specific package
pnpm update next --filter=@my-saas/web
# Check for outdated packages
pnpm outdated -r
Common Patterns
Cross-Package Imports
// apps/web/src/app/page.tsx
import { Button, Card } from '@my-saas/ui';
import { db } from '@my-saas/database';
import { auth } from '@my-saas/auth';
export default async function HomePage() {
const session = await auth();
const user = session ? await db.users.getById(session.user.id) : null;
return (
<Card>
<h1>Welcome {user?.name}</h1>
<Button>Get Started</Button>
</Card>
);
}
Shared Utilities
// packages/utils/src/formatters.ts
export function formatCurrency(amount: number, currency = 'USD') {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount);
}
export function formatDate(date: Date | string) {
return new Intl.DateTimeFormat('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
}).format(new Date(date));
}
Performance Optimization
Parallel Execution
Turbo runs tasks in parallel across packages:
# Runs lint in all packages simultaneously
turbo run lint
# Build with maximum parallelism
turbo run build --concurrency=10
Incremental Builds
// turbo.json
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**"],
"cache": true,
"incremental": true
}
}
}
Troubleshooting
Cache Issues
# Clear Turbo cache
turbo run clean
# Force rebuild without cache
turbo run build --force
Dependency Resolution
# Check why package is installed
pnpm why react
# List all dependencies
pnpm list -r --depth=0
💡Key Takeaways
- 1Turbo monorepos reduce build times by 60% through intelligent caching and parallel execution
- 2Shared packages eliminate code duplication and ensure consistency across apps
- 3Remote caching with Vercel enables team-wide build cache sharing
- 4Workspace protocol (workspace:*) ensures internal packages always use latest local version
- 5Proper package structure enables independent app deployments while sharing core logic
- 6Environment variable management requires clear separation between shared and app-specific vars
- 7Migration from multiple repos reduced deployment complexity by 10x
- 8TypeScript path mapping and transpilation config critical for Next.js monorepos
Turbo monorepos transform multi-app platforms from dependency management nightmares into streamlined, fast, and maintainable systems.
