Next.js Monorepo with Turborepo: Shared Packages, Remote Caching, and CI Pipeline
Set up a production Next.js monorepo with Turborepo. Covers workspace configuration, shared UI and config packages, TypeScript path aliases, remote caching with Vercel, and GitHub Actions CI pipeline.
As products grow, the "one repo per app" approach breaks down. Your web app, marketing site, and admin panel share the same design system. Your API types need to be available in both the frontend and the backend CLI. Without a monorepo, you're copying code or publishing private packages โ both create drift.
Turborepo solves this with task pipelines that understand the dependency graph, build caching that skips unchanged work, and remote caching that makes CI fast even as the repo grows. Combined with pnpm workspaces, it's the standard setup for production Next.js monorepos in 2027.
Repository Structure
my-app/
โโโ apps/
โ โโโ web/ # Main Next.js app
โ โโโ admin/ # Admin Next.js app
โ โโโ docs/ # Documentation site
โโโ packages/
โ โโโ ui/ # Shared React components
โ โโโ config/
โ โ โโโ eslint/ # Shared ESLint config
โ โ โโโ typescript/ # Shared tsconfig bases
โ โ โโโ tailwind/ # Shared Tailwind config
โ โโโ database/ # Prisma schema + client
โ โโโ validators/ # Shared Zod schemas
โโโ turbo.json # Turborepo pipeline config
โโโ pnpm-workspace.yaml
โโโ package.json
Initial Setup
# Create monorepo with Turborepo
npx create-turbo@latest my-app
cd my-app
# Or add Turborepo to existing pnpm workspace
pnpm add turbo -D -w # -w = workspace root
# Add apps and packages
mkdir -p apps/web apps/admin packages/ui packages/config/eslint packages/config/typescript packages/config/tailwind packages/database packages/validators
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
pnpm Workspace Configuration
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
- "packages/config/*"
// package.json (root)
{
"name": "my-monorepo",
"private": true,
"engines": { "node": ">=22", "pnpm": ">=9" },
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --parallel",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
"test": "turbo run test",
"clean": "turbo run clean && rm -rf node_modules"
},
"devDependencies": {
"turbo": "^2.0.0"
}
}
Turborepo Pipeline Configuration
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"ui": "tui",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"],
"env": [
"NODE_ENV",
"NEXT_PUBLIC_API_URL",
"NEXT_PUBLIC_APP_URL"
]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"],
"outputs": []
},
"typecheck": {
"dependsOn": ["^build"],
"outputs": []
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"],
"env": ["CI"]
},
"clean": {
"cache": false
},
"db:generate": {
"cache": false,
"outputs": ["node_modules/.prisma/**", "src/generated/**"]
},
"db:push": {
"cache": false
}
},
"globalDependencies": [
"pnpm-lock.yaml"
]
}
The "dependsOn": ["^build"] means: before running build for this package, run build for all packages it depends on. Turborepo resolves the correct order automatically.
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Shared TypeScript Config Package
// packages/config/typescript/package.json
{
"name": "@my-app/typescript-config",
"version": "0.0.1",
"private": true,
"exports": {
"./base": "./base.json",
"./nextjs": "./nextjs.json",
"./library": "./library.json"
}
}
// packages/config/typescript/base.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": false,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true
}
}
// packages/config/typescript/nextjs.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"noEmit": true,
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {}
}
}
// packages/config/typescript/library.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"noEmit": false
}
}
Shared UI Package
// packages/ui/package.json
{
"name": "@my-app/ui",
"version": "0.0.1",
"private": true,
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts"
},
"./button": {
"import": "./src/components/button.tsx",
"types": "./src/components/button.tsx"
},
"./styles": "./src/styles/globals.css"
},
"scripts": {
"lint": "eslint src/",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@my-app/typescript-config": "workspace:*",
"@my-app/tailwind-config": "workspace:*",
"typescript": "catalog:"
}
}
// packages/ui/src/components/button.tsx
import React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
primary: "bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-600",
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 focus-visible:ring-gray-500",
outline: "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50",
ghost: "text-gray-700 hover:bg-gray-100",
destructive: "bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-600",
},
size: {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-11 px-6 text-base",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
isLoading?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, isLoading, children, disabled, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size }), className)}
ref={ref}
disabled={disabled || isLoading}
aria-busy={isLoading}
{...props}
>
{isLoading && (
<svg
className="animate-spin h-4 w-4"
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";
// packages/ui/src/index.ts
export { Button } from "./components/button";
export type { ButtonProps } from "./components/button";
export { Input } from "./components/input";
export { Badge } from "./components/badge";
export { Card, CardHeader, CardContent, CardFooter } from "./components/card";
export { Dialog, DialogContent, DialogHeader, DialogTitle } from "./components/dialog";
// ... other components
Shared Database Package
// packages/database/package.json
{
"name": "@my-app/database",
"version": "0.0.1",
"private": true,
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts"
}
},
"scripts": {
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate deploy",
"db:studio": "prisma studio"
},
"dependencies": {
"@prisma/client": "catalog:"
},
"devDependencies": {
"prisma": "catalog:",
"@my-app/typescript-config": "workspace:*"
}
}
// packages/database/src/index.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
// Re-export all Prisma types for consumers
export * from "@prisma/client";
export type { PrismaClient } from "@prisma/client";
Shared Validators Package
// packages/validators/package.json
{
"name": "@my-app/validators",
"version": "0.0.1",
"private": true,
"exports": {
".": "./src/index.ts",
"./auth": "./src/auth.ts",
"./workspace": "./src/workspace.ts"
},
"dependencies": {
"zod": "catalog:"
}
}
// packages/validators/src/workspace.ts
import { z } from "zod";
export const CreateWorkspaceSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters").max(50),
slug: z
.string()
.min(2)
.max(30)
.regex(/^[a-z0-9-]+$/, "Slug must be lowercase alphanumeric with hyphens"),
});
export const InviteMemberSchema = z.object({
email: z.string().email(),
role: z.enum(["admin", "member", "viewer"]),
});
export type CreateWorkspaceInput = z.infer<typeof CreateWorkspaceSchema>;
export type InviteMemberInput = z.infer<typeof InviteMemberSchema>;
Next.js App Configuration
// apps/web/package.json
{
"name": "@my-app/web",
"version": "0.0.1",
"private": true,
"scripts": {
"build": "next build",
"dev": "next dev --port 3000",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"clean": "rm -rf .next out"
},
"dependencies": {
"@my-app/ui": "workspace:*",
"@my-app/database": "workspace:*",
"@my-app/validators": "workspace:*",
"next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@my-app/typescript-config": "workspace:*",
"@my-app/eslint-config": "workspace:*",
"@my-app/tailwind-config": "workspace:*",
"typescript": "catalog:"
}
}
// apps/web/tsconfig.json
{
"extends": "@my-app/typescript-config/nextjs",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
// apps/web/next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = {
// Transpile internal workspace packages
transpilePackages: ["@my-app/ui"],
experimental: {
// Enable React compiler (2027)
reactCompiler: true,
},
};
export default config;
pnpm Catalog (Version Pinning)
pnpm 9+ supports a catalog for pinning versions across all packages:
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
- "packages/config/*"
catalog:
# Framework
next: "15.3.0"
react: "19.1.0"
react-dom: "19.1.0"
typescript: "5.7.0"
# Database
"@prisma/client": "6.2.0"
prisma: "6.2.0"
# Validation
zod: "3.24.0"
# Testing
vitest: "2.1.0"
"@testing-library/react": "16.0.0"
Now all packages reference "next": "catalog:" and get the same pinned version automatically.
Remote Caching with Vercel
# Link to Vercel Remote Cache (free for Vercel-deployed projects)
npx turbo login
npx turbo link
# Or use self-hosted cache (Turborepo supports S3-compatible backends)
# Set TURBO_API, TURBO_TOKEN, TURBO_TEAM env vars
# apps/web/.env.local (never commit)
TURBO_TOKEN=your-vercel-token
TURBO_TEAM=your-team-name
Remote caching means: when a PR's build hits CI, unchanged packages restore from cache instead of rebuilding. A monorepo with 10 packages where only 2 changed rebuilds only those 2.
GitHub Actions CI Pipeline
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
jobs:
ci:
name: Lint, Typecheck, Test
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2 # Required for Turborepo's affected detection
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate Prisma client
run: pnpm --filter @my-app/database db:generate
- name: Lint
run: pnpm turbo lint
- name: Typecheck
run: pnpm turbo typecheck
- name: Test
run: pnpm turbo test
env:
CI: true
build:
name: Build
runs-on: ubuntu-latest
timeout-minutes: 20
needs: ci
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate Prisma client
run: pnpm --filter @my-app/database db:generate
- name: Build all apps
run: pnpm turbo build
env:
NEXT_PUBLIC_API_URL: ${{ vars.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_APP_URL: ${{ vars.NEXT_PUBLIC_APP_URL }}
- name: Upload web build artifact
uses: actions/upload-artifact@v4
with:
name: web-build
path: apps/web/.next
retention-days: 1
Running Specific Apps and Filters
# Run dev for a specific app only
pnpm turbo dev --filter=@my-app/web
# Build only apps that depend on @my-app/ui (after UI changes)
pnpm turbo build --filter=...[HEAD^1] # changed since last commit
# Build only a specific app and its dependencies
pnpm turbo build --filter=@my-app/web...
# Run command in a specific package directly
pnpm --filter @my-app/database prisma studio
# Add a dependency to a specific workspace
pnpm --filter @my-app/web add @tanstack/react-query
# Add a shared dev dependency to root
pnpm add -D prettier -w
Package Versioning Strategy
For internal packages in a monorepo, use "workspace:*" to always reference the local version:
{
"dependencies": {
"@my-app/ui": "workspace:*",
"@my-app/database": "workspace:*"
}
}
If you eventually need to publish packages externally, use Changesets:
pnpm add -D @changesets/cli -w
pnpm changeset init
# Create a changeset when making changes
pnpm changeset
# Version and publish
pnpm changeset version
pnpm changeset publish
Cost and Timeline Estimates
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Basic monorepo setup (2 apps, shared UI) | 1 dev | 1โ2 days | $400โ800 |
| Full setup (5+ packages, remote cache, CI) | 1โ2 devs | 3โ5 days | $1,200โ2,500 |
| Migration from separate repos | 1โ2 devs | 1โ2 weeks | $3,000โ6,000 |
| Enterprise monorepo (module federation, custom registry) | 2โ3 devs | 3โ5 weeks | $8,000โ20,000 |
See Also
- Next.js App Router Caching Strategies
- TypeScript Utility Types for Production Code
- React Server Components Patterns
- Terraform State Management and Remote Backends
- AWS ECS Fargate Production Deployment
Working With Viprasol
Monorepos solve real problems โ shared code, coordinated versioning, unified CI โ but the initial setup and migration require careful planning to avoid the pitfalls: circular dependencies, overly-coupled packages, slow CI without caching. Our team has set up Turborepo monorepos for products ranging from two-app setups to repositories with 20+ packages.
What we deliver:
- Turborepo pipeline configuration with correct dependency ordering
- Shared packages: UI components, TypeScript configs, Zod validators, Prisma client
- Remote caching setup (Vercel or self-hosted S3)
- GitHub Actions CI pipeline with cache-aware builds
- Migration plan for moving from separate repos
Talk to our team about your monorepo architecture โ
Or explore our web development services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.