Next.js

Monorepo with Next.js

28 min Lesson 33 of 40

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 first
  • outputs - Files to cache for future runs
  • inputs - Files that affect task output
  • cache: false - Disable caching for dev/watch tasks
  • persistent: 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:

  1. Apps:
    • Customer-facing store (Next.js)
    • Admin dashboard (Next.js)
    • API documentation (Next.js)
  2. Packages:
    • @repo/ui - Shared components (Button, Card, Modal)
    • @repo/database - Prisma schema and utilities
    • @repo/auth - Authentication logic
    • @repo/config - Shared configs (ESLint, TypeScript)
  3. Configure Turborepo with proper caching
  4. Set up GitHub Actions for CI/CD
  5. 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 --filter to run tasks only where needed
  • Configure proper outputs for 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.