All insights
Engineering7 min read

Monorepo Architecture for Next.js Projects

When your Next.js project grows into multiple apps sharing code, a monorepo is the natural solution. Here's how to set one up without the tooling overhead derailing you.

NC

Nextcraft Engineering Team

When You Actually Need a Monorepo

A monorepo isn't always the right choice. It's appropriate when:

  • You have 2+ Next.js apps that share significant code (a marketing site + a SaaS app + a docs site)
  • You maintain a shared component library consumed by multiple projects
  • You have a backend package (shared utilities, types, API clients) used across apps
  • You want atomic commits across packages ("this PR adds the API endpoint AND updates the frontend to use it")

If you have one Next.js app, a monorepo adds complexity with minimal benefit. Reach for it when the shared code problem is real, not anticipated.

Turborepo: The Practical Choice

Turborepo (from Vercel) is the monorepo build system most Next.js teams reach for. It handles build caching, parallel task execution, and dependency-aware build ordering.

code
npx create-turbo@latest

The default structure:

code
my-monorepo/
├── apps/
│   ├── web/          — Marketing site (Next.js)
│   ├── app/          — SaaS product (Next.js)
│   └── docs/         — Documentation site (Next.js)
├── packages/
│   ├── ui/           — Shared component library
│   ├── config/       — Shared ESLint, TypeScript, Tailwind configs
│   └── db/           — Database client and Prisma schema
├── turbo.json
└── package.json      — pnpm/npm workspaces config

Setting Up the Workspace

Using pnpm (recommended for monorepos due to efficient disk usage):

code
# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"
code
// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {
      "dependsOn": ["^lint"]
    },
    "typecheck": {
      "dependsOn": ["^typecheck"]
    }
  }
}

The ^ prefix means "run this task in dependencies first." So build with dependsOn: ["^build"] ensures packages/ui builds before any apps/* that depend on it.

The Shared UI Package

code
// packages/ui/package.json
{
  "name": "@company/ui",
  "version": "0.0.1",
  "main": "./index.ts",
  "exports": {
    ".": "./index.ts"
  },
  "devDependencies": {
    "typescript": "catalog:",
    "react": "catalog:",
    "@types/react": "catalog:"
  },
  "peerDependencies": {
    "react": "^19.0.0"
  }
}
code
// packages/ui/index.ts
export { Button } from './components/Button';
export { Input } from './components/Input';
export { Modal } from './components/Modal';
export type { ButtonProps, InputProps, ModalProps } from './types';

Consuming in an app:

code
// apps/web/package.json
{
  "dependencies": {
    "@company/ui": "workspace:*"
  }
}
code
// apps/web/app/page.tsx
import { Button } from '@company/ui';

export default function Page() {
  return <Button variant="primary">Get Started</Button>;
}

Shared TypeScript and ESLint Configs

Rather than duplicating configuration across apps:

code
// packages/config/tsconfig/base.json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [{ "name": "next" }]
  }
}
code
// apps/web/tsconfig.json
{
  "extends": "@company/config/tsconfig/nextjs.json",
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

The Shared Database Package

If multiple apps share the same database:

code
// packages/db/index.ts
export { db } from './client';
export * from '@prisma/client'; // Re-export Prisma types
code
// packages/db/client.ts
import { PrismaClient } from '@prisma/client';

const globalForPrisma = global as unknown as { prisma: PrismaClient };

export const db = globalForPrisma.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = db;
}

Both the marketing site (for newsletter signups) and the SaaS app (for everything) can import from @company/db.

Running Tasks

code
# Run dev servers for all apps in parallel
pnpm turbo dev

# Run dev for only the web app
pnpm turbo dev --filter=web

# Build all apps (builds packages first due to dependency graph)
pnpm turbo build

# Run tests only for packages that changed since main branch
pnpm turbo test --filter="...[origin/main]"

Turborepo caches task outputs. If you run build and no files changed, subsequent builds are instant — the cached output is restored.

Deployment

Each app in the monorepo deploys independently. On Vercel:

  1. Create a separate Vercel project for each app in apps/
  2. Set the Root Directory to apps/web (or apps/app, etc.)
  3. Vercel automatically detects Turborepo and optimizes the build

For CI, use Turborepo's remote caching to share build cache across CI runs:

code
# .github/workflows/ci.yml
- name: Build
  run: pnpm turbo build
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: ${{ secrets.TURBO_TEAM }}

With remote caching, a PR that only changes apps/web won't rebuild apps/docs or packages/ui — only what's affected gets rebuilt. On large monorepos, this can cut CI time by 80%+.

Stay Informed.

Join 1,200+ founders and engineers receiving our monthly deep dives on product engineering, design, and growth.

Insights once a month. No spam. Unsubscribe anytime.