Build a Production Monorepo with Turborepo, Next.js, and Shared Packages

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

One repo to rule them all. Turborepo is the high-performance build system for JavaScript and TypeScript monorepos. In this tutorial, you will build a production-grade monorepo with multiple Next.js apps, shared UI components, a shared TypeScript config, and remote caching for blazing-fast CI builds.

What You Will Learn

By the end of this tutorial, you will:

  • Set up a Turborepo monorepo with pnpm workspaces
  • Create multiple Next.js apps that share code
  • Build a shared UI component library with TypeScript
  • Configure a shared TypeScript config package
  • Create a shared utilities package used across apps
  • Set up Turborepo pipelines for efficient task orchestration
  • Enable remote caching for near-instant CI builds
  • Deploy individual apps with proper CI/CD workflows

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ installed (node --version)
  • pnpm 9+ installed (pnpm --version) — if not: npm install -g pnpm
  • TypeScript experience (types, generics, module resolution)
  • Next.js familiarity (App Router, Server Components)
  • A code editor — VS Code or Cursor recommended

Why Turborepo?

As projects grow, you often end up with multiple apps (marketing site, dashboard, admin panel) and shared code (UI components, utilities, configs). You have two choices:

  1. Multi-repo: Separate repos for each package. Publishing, versioning, and keeping dependencies in sync becomes painful.
  2. Monorepo: All code in one repo with shared tooling. Easier to share code, atomic changes across packages, and unified CI/CD.

Turborepo makes monorepos practical by solving their biggest problem: build performance. It provides:

FeatureBenefit
Task hashingOnly rebuilds what changed
Parallel executionRuns independent tasks concurrently
Remote cachingShare build artifacts across team and CI
Pipeline orchestrationRespects dependency order automatically
Incremental adoptionAdd to existing repos without rewriting

Compared to alternatives like Nx, Turborepo is lighter, zero-config by default, and purpose-built for the JavaScript ecosystem.


Step 1: Scaffold the Monorepo

Create a new Turborepo project using the official starter:

pnpm dlx create-turbo@latest my-monorepo

When prompted:

  • Select pnpm as the package manager
  • Choose the default starter

Navigate into the project:

cd my-monorepo

You will see this structure:

my-monorepo/
├── apps/
│   ├── web/          # Next.js app
│   └── docs/         # Next.js docs app
├── packages/
│   ├── ui/           # Shared UI components
│   ├── eslint-config/  # Shared ESLint config
│   └── typescript-config/  # Shared tsconfig
├── turbo.json        # Turborepo pipeline config
├── pnpm-workspace.yaml
└── package.json

Let us understand each piece before customizing it.


Step 2: Understand the Workspace Structure

Root package.json

The root package.json defines workspace-level scripts and dev dependencies:

{
  "name": "my-monorepo",
  "private": true,
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "lint": "turbo run lint",
    "format": "prettier --write \"**/*.{ts,tsx,md}\""
  },
  "devDependencies": {
    "prettier": "^3.4.2",
    "turbo": "^2.4.4",
    "typescript": "^5.7.3"
  },
  "packageManager": "pnpm@9.15.4"
}

Notice: turbo run build does not build anything itself. It orchestrates the build script in each workspace package, respecting dependency order and caching results.

pnpm-workspace.yaml

This tells pnpm where to find workspace packages:

packages:
  - "apps/*"
  - "packages/*"

turbo.json

This is the heart of Turborepo — the pipeline configuration:

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "lint": {
      "dependsOn": ["^lint"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

Key concepts:

  • dependsOn: ["^build"] — The ^ means "run this task in dependencies first." If web depends on ui, then ui#build runs before web#build.
  • outputs — Files Turborepo should cache. On subsequent runs, if inputs have not changed, Turborepo replays the cached output instantly.
  • cache: false — Dev servers should never be cached.
  • persistent: true — Marks long-running tasks (like dev servers) that don't exit.

Step 3: Create a Shared UI Component Library

The starter includes a basic ui package. Let us enhance it with a proper component library.

Set Up the Package

Edit packages/ui/package.json:

{
  "name": "@repo/ui",
  "version": "0.0.1",
  "private": true,
  "exports": {
    "./button": "./src/button.tsx",
    "./card": "./src/card.tsx",
    "./input": "./src/input.tsx",
    "./badge": "./src/badge.tsx",
    "./dialog": "./src/dialog.tsx"
  },
  "devDependencies": {
    "@repo/typescript-config": "workspace:*",
    "typescript": "^5.7.3"
  },
  "peerDependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}

The exports field is critical — it defines the public API of your package. Instead of one barrel export, each component gets its own entry point. This enables tree-shaking so unused components are not bundled.

Create the Button Component

Create packages/ui/src/button.tsx:

import { type ButtonHTMLAttributes, forwardRef } from "react";
 
type ButtonVariant = "primary" | "secondary" | "outline" | "ghost" | "danger";
type ButtonSize = "sm" | "md" | "lg";
 
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: ButtonVariant;
  size?: ButtonSize;
  isLoading?: boolean;
}
 
const variantStyles: Record<ButtonVariant, string> = {
  primary:
    "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
  secondary:
    "bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500",
  outline:
    "border-2 border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-gray-500",
  ghost:
    "text-gray-700 hover:bg-gray-100 focus:ring-gray-500",
  danger:
    "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
};
 
const sizeStyles: Record<ButtonSize, string> = {
  sm: "px-3 py-1.5 text-sm",
  md: "px-4 py-2 text-base",
  lg: "px-6 py-3 text-lg",
};
 
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ variant = "primary", size = "md", isLoading, className = "", children, disabled, ...props }, ref) => {
    return (
      <button
        ref={ref}
        className={`inline-flex items-center justify-center rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ${variantStyles[variant]} ${sizeStyles[size]} ${className}`}
        disabled={disabled || isLoading}
        {...props}
      >
        {isLoading && (
          <svg
            className="mr-2 h-4 w-4 animate-spin"
            xmlns="http://www.w3.org/2000/svg"
            fill="none"
            viewBox="0 0 24 24"
          >
            <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
            <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
          </svg>
        )}
        {children}
      </button>
    );
  }
);
 
Button.displayName = "Button";
 
export { Button, type ButtonProps };

Create the Card Component

Create packages/ui/src/card.tsx:

import type { HTMLAttributes, ReactNode } from "react";
 
interface CardProps extends HTMLAttributes<HTMLDivElement> {
  children: ReactNode;
}
 
function Card({ children, className = "", ...props }: CardProps) {
  return (
    <div
      className={`rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md ${className}`}
      {...props}
    >
      {children}
    </div>
  );
}
 
function CardHeader({ children, className = "", ...props }: CardProps) {
  return (
    <div className={`mb-4 ${className}`} {...props}>
      {children}
    </div>
  );
}
 
function CardTitle({ children, className = "", ...props }: CardProps) {
  return (
    <h3 className={`text-lg font-semibold text-gray-900 ${className}`} {...props}>
      {children}
    </h3>
  );
}
 
function CardContent({ children, className = "", ...props }: CardProps) {
  return (
    <div className={`text-gray-600 ${className}`} {...props}>
      {children}
    </div>
  );
}
 
export { Card, CardHeader, CardTitle, CardContent };

Create the Input Component

Create packages/ui/src/input.tsx:

import { type InputHTMLAttributes, forwardRef } from "react";
 
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
  label?: string;
  error?: string;
}
 
const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, className = "", id, ...props }, ref) => {
    const inputId = id || label?.toLowerCase().replace(/\s+/g, "-");
    return (
      <div className="space-y-1">
        {label && (
          <label htmlFor={inputId} className="block text-sm font-medium text-gray-700">
            {label}
          </label>
        )}
        <input
          ref={ref}
          id={inputId}
          className={`block w-full rounded-lg border px-3 py-2 text-gray-900 shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-offset-1 ${
            error
              ? "border-red-300 focus:border-red-500 focus:ring-red-500"
              : "border-gray-300 focus:border-blue-500 focus:ring-blue-500"
          } ${className}`}
          {...props}
        />
        {error && <p className="text-sm text-red-600">{error}</p>}
      </div>
    );
  }
);
 
Input.displayName = "Input";
 
export { Input, type InputProps };

Create the Badge Component

Create packages/ui/src/badge.tsx:

import type { HTMLAttributes } from "react";
 
type BadgeVariant = "default" | "success" | "warning" | "error" | "info";
 
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
  variant?: BadgeVariant;
}
 
const variantStyles: Record<BadgeVariant, string> = {
  default: "bg-gray-100 text-gray-800",
  success: "bg-green-100 text-green-800",
  warning: "bg-yellow-100 text-yellow-800",
  error: "bg-red-100 text-red-800",
  info: "bg-blue-100 text-blue-800",
};
 
function Badge({ variant = "default", className = "", children, ...props }: BadgeProps) {
  return (
    <span
      className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${variantStyles[variant]} ${className}`}
      {...props}
    >
      {children}
    </span>
  );
}
 
export { Badge, type BadgeProps };

Step 4: Create a Shared Utilities Package

Real monorepos share more than components. Create a utilities package for shared logic.

Initialize the Package

mkdir -p packages/utils/src

Create packages/utils/package.json:

{
  "name": "@repo/utils",
  "version": "0.0.1",
  "private": true,
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "exports": {
    ".": "./src/index.ts",
    "./cn": "./src/cn.ts",
    "./format": "./src/format.ts",
    "./validators": "./src/validators.ts"
  },
  "devDependencies": {
    "@repo/typescript-config": "workspace:*",
    "typescript": "^5.7.3"
  }
}

Create Utility Functions

Create packages/utils/src/cn.ts — the classic class name merger:

type ClassValue = string | number | boolean | undefined | null | ClassValue[];
 
export function cn(...inputs: ClassValue[]): string {
  return inputs
    .flat(Infinity)
    .filter((x) => typeof x === "string" && x.length > 0)
    .join(" ");
}

Create packages/utils/src/format.ts:

export function formatCurrency(amount: number, currency = "USD", locale = "en-US"): string {
  return new Intl.NumberFormat(locale, {
    style: "currency",
    currency,
  }).format(amount);
}
 
export function formatDate(date: Date | string, locale = "en-US"): string {
  const d = typeof date === "string" ? new Date(date) : date;
  return new Intl.DateTimeFormat(locale, {
    year: "numeric",
    month: "long",
    day: "numeric",
  }).format(d);
}
 
export function slugify(text: string): string {
  return text
    .toLowerCase()
    .trim()
    .replace(/[^\w\s-]/g, "")
    .replace(/[\s_]+/g, "-")
    .replace(/^-+|-+$/g, "");
}

Create packages/utils/src/validators.ts:

export function isValidEmail(email: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
 
export function isNotEmpty(value: string): boolean {
  return value.trim().length > 0;
}
 
export function isWithinLength(value: string, min: number, max: number): boolean {
  const len = value.trim().length;
  return len >= min && len <= max;
}

Create packages/utils/src/index.ts:

export { cn } from "./cn";
export { formatCurrency, formatDate, slugify } from "./format";
export { isValidEmail, isNotEmpty, isWithinLength } from "./validators";

Add a tsconfig.json at packages/utils/tsconfig.json:

{
  "extends": "@repo/typescript-config/base.json",
  "compilerOptions": {
    "outDir": "dist"
  },
  "include": ["src"]
}

Step 5: Configure the Web App to Use Shared Packages

Now wire the shared packages into your Next.js web app.

Add Dependencies

In apps/web/package.json, add the workspace dependencies:

{
  "dependencies": {
    "@repo/ui": "workspace:*",
    "@repo/utils": "workspace:*",
    "next": "^15.2.3",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}

Run pnpm install from the root to link everything:

pnpm install

Configure Next.js for Monorepo

Next.js needs to know about packages outside its directory. Edit apps/web/next.config.ts:

import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  transpilePackages: ["@repo/ui", "@repo/utils"],
};
 
export default nextConfig;

The transpilePackages option tells Next.js to compile these workspace packages through its bundler, so you can write TypeScript directly without a separate build step.

Use Shared Components

Now use the shared packages in your app. Edit apps/web/app/page.tsx:

import { Button } from "@repo/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@repo/ui/card";
import { Input } from "@repo/ui/input";
import { Badge } from "@repo/ui/badge";
import { formatDate } from "@repo/utils/format";
 
export default function Home() {
  return (
    <main className="mx-auto max-w-4xl p-8">
      <div className="mb-8">
        <h1 className="text-4xl font-bold text-gray-900">
          Turborepo Monorepo
        </h1>
        <p className="mt-2 text-lg text-gray-600">
          Built with shared packages — {formatDate(new Date())}
        </p>
      </div>
 
      <div className="grid gap-6 md:grid-cols-2">
        <Card>
          <CardHeader>
            <CardTitle>Shared UI Components</CardTitle>
          </CardHeader>
          <CardContent>
            <p className="mb-4">
              These components come from <code>@repo/ui</code>:
            </p>
            <div className="flex flex-wrap gap-2">
              <Button variant="primary">Primary</Button>
              <Button variant="secondary">Secondary</Button>
              <Button variant="outline">Outline</Button>
              <Button variant="ghost">Ghost</Button>
            </div>
          </CardContent>
        </Card>
 
        <Card>
          <CardHeader>
            <CardTitle>Badges &amp; Inputs</CardTitle>
          </CardHeader>
          <CardContent>
            <div className="mb-4 flex gap-2">
              <Badge variant="success">Active</Badge>
              <Badge variant="warning">Pending</Badge>
              <Badge variant="error">Failed</Badge>
              <Badge variant="info">Info</Badge>
            </div>
            <Input label="Email" placeholder="you@example.com" />
          </CardContent>
        </Card>
      </div>
    </main>
  );
}

Run the dev server to verify:

pnpm dev --filter=web

The --filter=web flag tells Turborepo to only run the dev task for the web app (and its dependencies).


Step 6: Add a Second App (Admin Dashboard)

The real power of monorepos shines when you have multiple apps sharing code.

Create the Admin App

cd apps
pnpm dlx create-next-app@latest admin --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"

Wire Up Shared Dependencies

Edit apps/admin/package.json to add workspace dependencies:

{
  "dependencies": {
    "@repo/ui": "workspace:*",
    "@repo/utils": "workspace:*",
    "next": "^15.2.3",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}

Update apps/admin/next.config.ts:

import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  transpilePackages: ["@repo/ui", "@repo/utils"],
};
 
export default nextConfig;

Run install from the root:

cd ..
pnpm install

Build the Admin Dashboard Page

Create apps/admin/app/page.tsx:

import { Button } from "@repo/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@repo/ui/card";
import { Badge } from "@repo/ui/badge";
import { formatCurrency, formatDate } from "@repo/utils/format";
 
const stats = [
  { label: "Total Revenue", value: formatCurrency(48250), change: "+12.5%" },
  { label: "Active Users", value: "2,420", change: "+8.1%" },
  { label: "Orders Today", value: "145", change: "+3.2%" },
  { label: "Conversion Rate", value: "3.6%", change: "-0.4%" },
];
 
const recentOrders = [
  { id: "ORD-001", customer: "Alice Johnson", amount: 299, status: "completed" as const },
  { id: "ORD-002", customer: "Bob Smith", amount: 149, status: "pending" as const },
  { id: "ORD-003", customer: "Charlie Brown", amount: 499, status: "completed" as const },
  { id: "ORD-004", customer: "Diana Ross", amount: 89, status: "failed" as const },
];
 
const statusVariant = {
  completed: "success",
  pending: "warning",
  failed: "error",
} as const;
 
export default function AdminDashboard() {
  return (
    <main className="mx-auto max-w-6xl p-8">
      <div className="mb-8 flex items-center justify-between">
        <div>
          <h1 className="text-3xl font-bold text-gray-900">Admin Dashboard</h1>
          <p className="text-gray-500">{formatDate(new Date())}</p>
        </div>
        <Button variant="primary">Export Report</Button>
      </div>
 
      <div className="mb-8 grid gap-4 md:grid-cols-4">
        {stats.map((stat) => (
          <Card key={stat.label}>
            <CardContent>
              <p className="text-sm text-gray-500">{stat.label}</p>
              <p className="mt-1 text-2xl font-bold">{stat.value}</p>
              <Badge variant={stat.change.startsWith("+") ? "success" : "error"}>
                {stat.change}
              </Badge>
            </CardContent>
          </Card>
        ))}
      </div>
 
      <Card>
        <CardHeader>
          <CardTitle>Recent Orders</CardTitle>
        </CardHeader>
        <CardContent>
          <table className="w-full">
            <thead>
              <tr className="border-b text-left text-sm text-gray-500">
                <th className="pb-3">Order ID</th>
                <th className="pb-3">Customer</th>
                <th className="pb-3">Amount</th>
                <th className="pb-3">Status</th>
              </tr>
            </thead>
            <tbody>
              {recentOrders.map((order) => (
                <tr key={order.id} className="border-b last:border-0">
                  <td className="py-3 font-mono text-sm">{order.id}</td>
                  <td className="py-3">{order.customer}</td>
                  <td className="py-3">{formatCurrency(order.amount)}</td>
                  <td className="py-3">
                    <Badge variant={statusVariant[order.status]}>{order.status}</Badge>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </CardContent>
      </Card>
    </main>
  );
}

Now both web and admin share the same UI components and utilities. Change a component once, and it updates everywhere.


Step 7: Configure Turborepo Pipelines

Update turbo.json to handle all tasks efficiently:

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "lint": {
      "dependsOn": ["^lint"]
    },
    "type-check": {
      "dependsOn": ["^build"]
    },
    "test": {
      "dependsOn": ["build"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "clean": {
      "cache": false
    }
  }
}

Add the new scripts to root package.json:

{
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "dev:web": "turbo run dev --filter=web",
    "dev:admin": "turbo run dev --filter=admin",
    "lint": "turbo run lint",
    "type-check": "turbo run type-check",
    "test": "turbo run test",
    "clean": "turbo run clean && rm -rf node_modules .turbo"
  }
}

Understanding Task Dependencies

The dependency graph is key to Turborepo's efficiency:

build task flow:
  @repo/typescript-config (no build needed)
  → @repo/utils#build
  → @repo/ui#build
  → web#build (depends on ui + utils)
  → admin#build (depends on ui + utils)

Turborepo runs utils#build and ui#build in parallel (they are independent), then runs web#build and admin#build in parallel once their dependencies finish.


Step 8: Enable Remote Caching

Remote caching is Turborepo's killer feature. It lets you share build artifacts between your local machine, teammates, and CI — so no one ever rebuilds what someone else already built.

npx turbo login
npx turbo link

This connects your monorepo to Vercel's remote cache (free tier available). After linking, you will see a confirmation:

✓ Linked to my-org/my-monorepo

How It Works

  1. First build: Turborepo runs the task, hashes the inputs (source files, dependencies, env vars), and uploads the output to the remote cache.
  2. Second build (or CI, or a teammate): Turborepo computes the same hash, finds it in the remote cache, and downloads the output — skipping the actual build.
# First build — runs everything
pnpm build
# → web#build: cache miss, computing...
# → admin#build: cache miss, computing...
 
# Second build — instant replay
pnpm build
# → web#build: cache hit, replaying output
# → admin#build: cache hit, replaying output

For large monorepos, this can reduce CI times from 20 minutes to under 1 minute.

Self-Hosted Remote Cache

If you prefer not to use Vercel, you can self-host the cache server. Turborepo supports any S3-compatible storage:

// turbo.json
{
  "remoteCache": {
    "enabled": true,
    "signature": true
  }
}

Use the TURBO_API, TURBO_TOKEN, and TURBO_TEAM environment variables to configure a self-hosted endpoint.


Step 9: Set Up CI/CD with GitHub Actions

Create .github/workflows/ci.yml:

name: CI
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}
 
jobs:
  build:
    name: Build and Test
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
        with:
          version: 9
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          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

Deploy Only Changed Apps

Turborepo can detect which apps changed and only deploy those. Use the --filter flag with git diff:

  deploy-web:
    name: Deploy Web
    needs: build
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2
 
      - name: Check for changes
        id: changes
        run: |
          if npx turbo-ignore web; then
            echo "skip=true" >> $GITHUB_OUTPUT
          else
            echo "skip=false" >> $GITHUB_OUTPUT
          fi
 
      - name: Deploy web app
        if: steps.changes.outputs.skip == 'false'
        run: echo "Deploying web app..."
        # Add your deployment command here

The turbo-ignore command exits with code 0 if nothing changed for that package, letting you skip unnecessary deployments.


Step 10: Advanced Patterns

Environment Variables in Turborepo

If your build output depends on environment variables, you must tell Turborepo about them. Otherwise, it might serve a cached build that used different env values.

// turbo.json
{
  "globalEnv": ["NODE_ENV", "CI"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "env": ["DATABASE_URL", "NEXT_PUBLIC_API_URL"],
      "outputs": [".next/**", "!.next/cache/**"]
    }
  }
}
  • globalEnv — Variables that affect all tasks.
  • env — Variables that affect a specific task. Changes to these variables invalidate the cache.

Filtering Tasks

Turborepo's filter syntax is powerful:

# Run build for web and its dependencies
pnpm build --filter=web...
 
# Run build for everything except docs
pnpm build --filter=!docs
 
# Run build for packages that changed since main
pnpm build --filter=...[main]
 
# Run build for web and admin only
pnpm build --filter=web --filter=admin

Internal vs. Published Packages

Our packages are "private": true — they are internal, consumed only within the monorepo. For published packages (npm), you would add:

{
  "name": "@myorg/ui",
  "version": "1.0.0",
  "private": false,
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts"
  }
}

Use changesets for version management of published packages.


Step 11: Shared Tailwind CSS Configuration

When multiple apps use Tailwind, share the configuration to keep design tokens consistent.

Create packages/tailwind-config/package.json:

{
  "name": "@repo/tailwind-config",
  "version": "0.0.1",
  "private": true,
  "main": "./tailwind.config.ts",
  "exports": {
    ".": "./tailwind.config.ts"
  },
  "devDependencies": {
    "tailwindcss": "^4.0.0"
  }
}

Create packages/tailwind-config/tailwind.config.ts:

import type { Config } from "tailwindcss";
 
const config: Config = {
  content: [
    "./app/**/*.{ts,tsx}",
    "./src/**/*.{ts,tsx}",
    "../../packages/ui/src/**/*.{ts,tsx}",
  ],
  theme: {
    extend: {
      colors: {
        brand: {
          50: "#eff6ff",
          100: "#dbeafe",
          200: "#bfdbfe",
          300: "#93c5fd",
          400: "#60a5fa",
          500: "#3b82f6",
          600: "#2563eb",
          700: "#1d4ed8",
          800: "#1e40af",
          900: "#1e3a8a",
          950: "#172554",
        },
      },
      fontFamily: {
        sans: ["Inter", "system-ui", "sans-serif"],
        mono: ["JetBrains Mono", "monospace"],
      },
    },
  },
  plugins: [],
};
 
export default config;

In each app, extend the shared config:

// apps/web/tailwind.config.ts
import sharedConfig from "@repo/tailwind-config";
import type { Config } from "tailwindcss";
 
const config: Config = {
  ...sharedConfig,
  content: [
    "./app/**/*.{ts,tsx}",
    "./src/**/*.{ts,tsx}",
    "../../packages/ui/src/**/*.{ts,tsx}",
  ],
};
 
export default config;

Testing Your Monorepo

Let us verify everything works end to end.

Build All Packages

pnpm build

You should see Turborepo orchestrate the build:

 Tasks:    4 successful, 4 total
Cached:    0 cached, 4 total
  Time:    12.4s

Run It Again

pnpm build

This time, everything is cached:

 Tasks:    4 successful, 4 total
Cached:    4 cached, 4 total
  Time:    0.3s  >>> FULL TURBO

That "FULL TURBO" message means every task was served from cache — zero work done.

Run Dev Servers

# All apps
pnpm dev
 
# Just the web app
pnpm dev:web

Troubleshooting

"Module not found" errors

If an app cannot find a workspace package:

  1. Check that the package is listed in the app's package.json dependencies
  2. Run pnpm install from the root
  3. Ensure transpilePackages includes the package in next.config.ts

Cache not working

If builds are not being cached:

  1. Run turbo run build --summarize to inspect the task hash
  2. Check that outputs in turbo.json match your actual build outputs
  3. Ensure environment variables are declared in env or globalEnv

TypeScript path errors

If your editor shows type errors for workspace packages:

  1. Restart the TypeScript server (Cmd+Shift+P → "TypeScript: Restart TS Server")
  2. Ensure each package has a proper tsconfig.json extending the shared config
  3. Check that exports in package.json points to the correct files

Project Structure Summary

Here is the complete monorepo structure you have built:

my-monorepo/
├── apps/
│   ├── web/                    # Marketing site
│   │   ├── app/page.tsx       # Uses @repo/ui + @repo/utils
│   │   ├── next.config.ts     # transpilePackages
│   │   └── package.json       # workspace:* deps
│   └── admin/                  # Admin dashboard
│       ├── app/page.tsx       # Uses @repo/ui + @repo/utils
│       ├── next.config.ts
│       └── package.json
├── packages/
│   ├── ui/                     # Shared UI components
│   │   └── src/
│   │       ├── button.tsx
│   │       ├── card.tsx
│   │       ├── input.tsx
│   │       └── badge.tsx
│   ├── utils/                  # Shared utilities
│   │   └── src/
│   │       ├── cn.ts
│   │       ├── format.ts
│   │       └── validators.ts
│   ├── tailwind-config/        # Shared Tailwind theme
│   ├── typescript-config/      # Shared tsconfig
│   └── eslint-config/          # Shared ESLint rules
├── .github/workflows/ci.yml   # CI with remote caching
├── turbo.json                  # Pipeline config
├── pnpm-workspace.yaml
└── package.json

Next Steps

Now that you have a working monorepo, consider:

  • Add Storybook to the ui package for component documentation
  • Set up Changesets if you plan to publish packages to npm
  • Add E2E tests with Playwright in a separate packages/e2e package
  • Deploy to Vercel — it has first-class Turborepo support with automatic project detection
  • Add a shared database package with Drizzle ORM for consistent schema access

Conclusion

You have built a production-ready monorepo with Turborepo that includes:

  • Two Next.js apps sharing code seamlessly
  • A shared UI library with typed, reusable components
  • A shared utilities package for common logic
  • Shared TypeScript and Tailwind configurations
  • Efficient build pipelines with caching and parallelization
  • CI/CD workflows with selective deployment

Turborepo transforms monorepo management from a chore into a superpower. The combination of task hashing, parallel execution, and remote caching means your builds stay fast regardless of how large your codebase grows. Start small with a single shared package, and scale up as your project demands it.


Want to read more tutorials? Check out our latest tutorial on Vitest and React Testing Library with Next.js 15: The Complete Unit Testing Guide for 2026.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles