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

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:
- Multi-repo: Separate repos for each package. Publishing, versioning, and keeping dependencies in sync becomes painful.
- 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:
| Feature | Benefit |
|---|---|
| Task hashing | Only rebuilds what changed |
| Parallel execution | Runs independent tasks concurrently |
| Remote caching | Share build artifacts across team and CI |
| Pipeline orchestration | Respects dependency order automatically |
| Incremental adoption | Add 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-monorepoWhen prompted:
- Select pnpm as the package manager
- Choose the default starter
Navigate into the project:
cd my-monorepoYou 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." Ifwebdepends onui, thenui#buildruns beforeweb#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/srcCreate 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 installConfigure 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 & 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=webThe --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 installBuild 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.
Link Your Repository
npx turbo login
npx turbo linkThis 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
- First build: Turborepo runs the task, hashes the inputs (source files, dependencies, env vars), and uploads the output to the remote cache.
- 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 outputFor 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 testDeploy 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 hereThe 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=adminInternal 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 buildYou should see Turborepo orchestrate the build:
Tasks: 4 successful, 4 total
Cached: 0 cached, 4 total
Time: 12.4s
Run It Again
pnpm buildThis 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:webTroubleshooting
"Module not found" errors
If an app cannot find a workspace package:
- Check that the package is listed in the app's
package.jsondependencies - Run
pnpm installfrom the root - Ensure
transpilePackagesincludes the package innext.config.ts
Cache not working
If builds are not being cached:
- Run
turbo run build --summarizeto inspect the task hash - Check that
outputsinturbo.jsonmatch your actual build outputs - Ensure environment variables are declared in
envorglobalEnv
TypeScript path errors
If your editor shows type errors for workspace packages:
- Restart the TypeScript server (
Cmd+Shift+P→ "TypeScript: Restart TS Server") - Ensure each package has a proper
tsconfig.jsonextending the shared config - Check that
exportsinpackage.jsonpoints 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
uipackage for component documentation - Set up Changesets if you plan to publish packages to npm
- Add E2E tests with Playwright in a separate
packages/e2epackage - 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.
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

End-to-End Testing with Playwright and Next.js: From Zero to CI Pipeline
Learn how to set up Playwright for end-to-end testing in a Next.js application. This hands-on tutorial covers setup, Page Object Model, visual regression, accessibility testing, and CI/CD integration with GitHub Actions.

Building an Autonomous AI Agent with Agentic RAG and Next.js
Learn how to build an AI agent that autonomously decides when and how to retrieve information from vector databases. A comprehensive hands-on guide using Vercel AI SDK and Next.js with executable examples.

Better Auth with Next.js 15: The Complete Authentication Guide for 2026
Learn how to implement full-featured authentication in Next.js 15 using Better Auth. This tutorial covers email/password, OAuth, sessions, middleware protection, and role-based access control.