Back to Blog

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.

Viprasol Tech Team
March 13, 2027
13 min read

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

ScopeTeamTimelineCost Range
Basic monorepo setup (2 apps, shared UI)1 dev1โ€“2 days$400โ€“800
Full setup (5+ packages, remote cache, CI)1โ€“2 devs3โ€“5 days$1,200โ€“2,500
Migration from separate repos1โ€“2 devs1โ€“2 weeks$3,000โ€“6,000
Enterprise monorepo (module federation, custom registry)2โ€“3 devs3โ€“5 weeks$8,000โ€“20,000

See Also


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.

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

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

Viprasol ยท Web Development

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.