Back to Playbooks
Liamrex · Playbook

Building a Turbo Monorepo for Multi-Tenant SaaS

From Legacy CRA to Scalable Monorepo Architecture

Author: Liam ReckziegelLiamrex · Playbook · deep dive
10 min read
guide

Building a Turbo Monorepo for Multi-Tenant SaaS

From Legacy CRA to Scalable Monorepo Architecture

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
60%
Faster Builds
3 → 1
Repos Consolidated
40%
Less Duplicated Code
10x
Faster Deployment

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

Separate Repos

Three separate repositories:

  • Duplicate components across repos
  • Different package versions
  • Manual dependency syncing
  • Separate CI/CD pipelines
Turbo Monorepo

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.

Want help with this?

If you're facing similar challenges and need someone who can architect, build, and ship—let's talk.

Get in Touch →