Web Development
Next.jsMonorepoTurborepopnpmWorkspacesCI/CDTypeScript

Next.js Monorepo with Turborepo + pnpm Workspaces: A Practical Setup Guide for 2026

AO
Adrijan Omićević
·13 min read

# What You’ll Build and Why It Matters#

This guide shows a production-ready setup for a Next.js monorepo Turborepo pnpm workspaces stack with shared packages, consistent tooling, and CI-friendly caching.

A well-structured monorepo can reduce duplicated code, speed up builds, and standardize quality gates. Turborepo’s task graph and caching can cut CI time dramatically when only a subset of packages changes, which is common in real teams.

If you’re new to Next.js fundamentals, start with our baseline guide: Getting started with Next.js. If your main driver is shared UI, also read React component architecture for a scalable design system.

# When a Monorepo Makes Sense (and When It Doesn’t)#

A monorepo is not a default choice. It’s a leverage tool for specific organizational and technical realities.

Good fits for a Next.js monorepo#

Use a monorepo when at least one of these is true:

  • Multiple apps sharing code: marketing site, dashboard, admin portal, docs site, all reusing the same UI kit and auth utilities.
  • Shared UI and design system: one component library consumed by 2 to 10 apps, kept in sync.
  • Shared backend clients and types: shared OpenAPI clients, Zod schemas, or domain models used across apps.
  • Platform teams: one team maintains shared packages, other teams consume them with predictable versioning and CI checks.
  • You want consistent tooling: one lint config, one TS config, one test strategy, enforced across everything.

In practice, monorepos frequently reduce “configuration drift” across projects. That drift causes real production risk: inconsistent lint rules, outdated dependencies in one repo, missing security checks in another.

When a monorepo is a bad idea#

A monorepo can be the wrong move when:

  • You only have one app and no shared packages.
  • You have hard isolation requirements between projects, for example separate compliance scopes.
  • Your teams are not aligned on shared conventions, and the “central config” becomes a bottleneck.
  • Your CI infrastructure is slow and you cannot effectively use caching, making monorepo builds feel heavier than separate repos.

🎯 Key Takeaway: A monorepo pays off when you have multiple apps and shared packages. If you don’t, you are mostly buying complexity.

# Prerequisites#

RequirementVersionNotes
Node.js20+Use an LTS version for predictable CI behavior
pnpm9+Workspace support and efficient disk usage
GitHub Actions or similar CIExamples use GitHub Actions concepts
Next.js14 or 15Works with both, examples assume App Router patterns
TypeScript5+Strongly recommended for shared packages

# Repo Structure (Apps + Packages)#

A practical Turborepo structure separates deployable apps from shared packages:

PathTypeWhat goes here
apps/webNext.js appMain customer-facing web app
apps/adminNext.js appInternal admin dashboard
packages/uiShared packageDesign system components
packages/configShared packageESLint, TS config, Prettier config
packages/sharedShared packageUtilities, types, API clients
toolingOptionalScripts, generators, dev utilities

This structure supports independent deployments per app, while keeping shared code versioned together.

ℹ️ Note: Keeping config in a package like packages/config avoids copy-paste and makes upgrades a single change rather than a repo-wide cleanup.

# Step 1: Initialize Turborepo with pnpm Workspaces#

Create the repo and initialize Turborepo. You can start from Turbo’s official templates, but it’s worth understanding what’s created.

Bash
mkdir nextjs-monorepo
cd nextjs-monorepo
pnpm init
pnpm add -D turbo

Create pnpm-workspace.yaml at the repo root:

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

Update package.json scripts at the root to control all tasks:

JSON
{
  "name": "nextjs-monorepo",
  "private": true,
  "packageManager": "pnpm@9.0.0",
  "scripts": {
    "dev": "turbo run dev --parallel",
    "build": "turbo run build",
    "lint": "turbo run lint",
    "test": "turbo run test",
    "typecheck": "turbo run typecheck",
    "clean": "turbo run clean && rm -rf node_modules .turbo"
  },
  "devDependencies": {
    "turbo": "^2.0.0"
  }
}

Add Turbo task configuration#

Create turbo.json:

JSON
{
  "tasks": {
    "dev": {
      "cache": false,
      "persistent": true
    },
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**", "build/**"]
    },
    "lint": {
      "dependsOn": ["^lint"]
    },
    "typecheck": {
      "dependsOn": ["^typecheck"]
    },
    "test": {
      "dependsOn": ["^test"],
      "outputs": ["coverage/**"]
    },
    "clean": {
      "cache": false
    }
  }
}

The dependsOn rule ensures packages build before apps that consume them. The outputs list is crucial for correct caching in CI.

💡 Tip: Be conservative with outputs. If you forget an output folder, Turborepo will miss cache hits. If you include too much, you can accidentally cache non-deterministic artifacts.

# Step 2: Create Next.js Apps in apps/*#

Create two apps to demonstrate shared packages. Use your preferred Next.js bootstrap method, then move the folders into apps/.

Example folder setup:

Bash
mkdir -p apps/web apps/admin packages/ui packages/shared packages/config

Inside each app, you want consistent scripts. Example apps/web/package.json:

JSON
{
  "name": "web",
  "private": true,
  "scripts": {
    "dev": "next dev -p 3000",
    "build": "next build",
    "start": "next start -p 3000",
    "lint": "next lint",
    "typecheck": "tsc -p tsconfig.json --noEmit",
    "test": "vitest run",
    "clean": "rm -rf .next"
  }
}

Repeat for apps/admin and pick a different port.

Shared Next.js settings: ESLint and TS#

Next.js includes opinionated defaults. In a monorepo, the key is to centralize as much as possible without fighting the framework.

  • Keep Next.js-specific config inside each app.
  • Centralize ESLint rules, TS base config, and Prettier in packages/config.

# Step 3: Add Shared Packages (UI + Shared Utilities)#

packages/shared: types and utilities#

Create packages/shared/package.json:

JSON
{
  "name": "@acme/shared",
  "version": "0.0.0",
  "private": true,
  "main": "src/index.ts",
  "types": "src/index.ts",
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "lint": "eslint .",
    "typecheck": "tsc -p tsconfig.json --noEmit",
    "test": "vitest run",
    "clean": "rm -rf dist"
  }
}

Create packages/shared/src/index.ts:

TypeScript
export type User = {
  id: string;
  email: string;
};
 
export function formatUserLabel(user: User) {
  return `${user.email} (${user.id})`;
}

packages/ui: shared design system components#

A UI package should be explicit about React version and exports. Keep it simple and avoid bundling early unless needed.

Create packages/ui/package.json:

JSON
{
  "name": "@acme/ui",
  "version": "0.0.0",
  "private": true,
  "sideEffects": false,
  "main": "src/index.ts",
  "types": "src/index.ts",
  "peerDependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  },
  "scripts": {
    "lint": "eslint .",
    "typecheck": "tsc -p tsconfig.json --noEmit",
    "test": "vitest run",
    "clean": "rm -rf dist"
  }
}

Create packages/ui/src/index.ts:

TypeScript
export { Button } from "./components/Button";

Create packages/ui/src/components/Button.tsx:

TypeScript
import type { ButtonHTMLAttributes } from "react";
 
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
  variant?: "primary" | "secondary";
};
 
export function Button({ variant = "primary", ...props }: Props) {
  const className =
    variant === "primary" ? "btn btn-primary" : "btn btn-secondary";
  return <button className={className} {...props} />;
}

Consume shared packages in apps#

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

JSON
{
  "dependencies": {
    "@acme/shared": "workspace:*",
    "@acme/ui": "workspace:*"
  }
}

Then use them in app code, for example in a page:

TypeScript
import { Button } from "@acme/ui";
import { formatUserLabel } from "@acme/shared";
 
export default function Page() {
  const label = formatUserLabel({ id: "u1", email: "ana@company.com" });
  return (
    <main>
      <p>{label}</p>
      <Button>Continue</Button>
    </main>
  );
}

# Step 4: Centralize TypeScript, ESLint, and Formatting#

Central config makes monorepos pay off. Without it, you end up with multiple competing standards inside one repo.

TypeScript base config#

Create packages/config/tsconfig.base.json:

JSON
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM"],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

Then in each app and package, create a tsconfig.json that extends it. Example apps/web/tsconfig.json:

JSON
{
  "extends": "../../packages/config/tsconfig.base.json",
  "compilerOptions": {
    "jsx": "preserve"
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

ESLint config package#

Create packages/config/eslint-nextjs.cjs:

JavaScript
module.exports = {
  root: false,
  extends: ["next/core-web-vitals"],
  rules: {
    "no-console": ["warn", { allow: ["warn", "error"] }]
  }
};

In apps/web/.eslintrc.cjs:

JavaScript
module.exports = {
  extends: ["../../packages/config/eslint-nextjs.cjs"]
};

⚠️ Warning: Do not set root: true in multiple nested ESLint configs. You will get confusing rule resolution and inconsistent lint results between local and CI.

Formatting#

Prettier in a monorepo is typically root-level to keep it simple. Keep the config minimal and stable so it does not churn diffs.

# Step 5: Path Aliases That Work Across Apps and Packages#

Path aliases are one of the most common monorepo pain points, especially when each app defines @/* differently.

  • Use package imports for shared code: @acme/ui, @acme/shared.
  • Keep app-local aliases simple: @/components inside each app only.

In apps/web/tsconfig.json:

JSON
{
  "extends": "../../packages/config/tsconfig.base.json",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./*"]
    }
  }
}

Avoid creating a global alias that points into packages/* because it makes boundaries fuzzy and can break bundlers. Package imports are the clean contract.

Next.js transpilation for workspace packages#

If your UI package ships TypeScript source, Next.js may need to transpile it. In apps/web/next.config.js:

JavaScript
/** @type {import('next').NextConfig} */
const nextConfig = {
  transpilePackages: ["@acme/ui", "@acme/shared"]
};
 
module.exports = nextConfig;

This is often required to avoid “Unexpected token” errors or ESM and CJS mismatches.

# Step 6: Testing Setup for Apps and Packages#

A realistic monorepo separates test types:

LayerToolingGoal
Unit testsVitestFast feedback for pure logic and components
Integration testsVitest plus MSWAPI contract confidence without hitting real services
E2E testsPlaywrightFull user flows per app

For many teams, the best ROI is unit plus a small set of E2E smoke tests.

Add vitest to relevant packages and apps, then expose a consistent test script so Turbo can run it.

Example vitest.config.ts per package can be minimal, but keep config consistent to avoid fragmentation.

# Step 7: Production-Ready Environment Variables Strategy#

Environment variables become messy in monorepos because multiple apps may share keys with different values.

Rules that keep you safe#

  • Use .env.local per app for developer overrides.
  • Keep .env.example per app to document required keys.
  • Never rely on root .env to configure multiple apps unless you have a strict naming convention.
ScenarioRecommended
Shared key across appsUse same name, but store values per app environment in hosting platform
App-specific keyPrefix with app name, for example WEB_SOME_KEY, ADMIN_SOME_KEY
Public keys in Next.jsUse NEXT_PUBLIC_ prefix, verify they are safe to expose

CI and caching impact#

If you include environment variables in build steps, caching becomes tricky. In Turbo, you can declare task inputs as environment variables in turbo.json to prevent incorrect cache reuse.

Keep it explicit. A wrong cache hit can produce a build artifact wired to the wrong API URL.

ℹ️ Note: Treat environment variables as build inputs. If a value changes, you want a cache miss, not a reused artifact.

# Step 8: CI Considerations: Caching, Filters, and Affected Builds#

Turborepo is most valuable in CI. Without remote caching, you still get local developer speedups, but CI remains expensive.

Build only what changed#

Use filters to run tasks only for affected apps:

  • turbo run build --filter=web
  • turbo run test --filter=... for scoped checks

In CI, you can map changed paths to filters. This keeps a monorepo from becoming “always build everything”.

Enable remote caching#

Remote caching is the difference between “nice” and “game-changing”. Teams commonly see 30 to 70 percent CI time reduction once caching is stable, especially when builds are dominated by dependency install plus Next.js compilation.

At minimum, cache:

  • pnpm store
  • Turbo cache directory
  • Next.js build outputs if applicable

Observability in CI and production#

Monorepos make it easier to standardize logging and monitoring across apps. Use one shared package for logging utilities and one convention for correlation IDs.

If you want a practical approach to tracing and metrics, use our guide: Web app observability guide: logging, metrics, tracing.

# Trade-offs: What You Gain vs What You Pay#

A monorepo is a set of trade-offs, not a free upgrade.

AspectBenefitCost
Shared codeOne source of truth, fewer duplicated fixesStronger coupling between apps
ToolingUnified lint, TS, test strategyMore upfront setup, more “platform” work
CICaching and affected builds can be very fastMisconfigured caching causes confusing failures
DependenciesOne place to upgrade versionsDependency conflicts affect more projects
Developer experienceEasier cross-app changesRepo can feel heavy without good scripts

If you don’t invest in boundaries, the monorepo becomes a dumping ground. If you do invest, it becomes a multiplier.

# Common Pitfalls and How to Avoid Them#

Pitfall 1: Broken workspace imports in Next.js builds#

Symptoms include module parse errors or React duplication warnings.

Fix checklist:

  1. 1
    Add transpilePackages for workspace packages.
  2. 2
    Use peerDependencies for React in UI packages.
  3. 3
    Ensure only one React version is installed in the workspace lockfile.

Pitfall 2: Path alias confusion between apps#

If multiple apps define @/ differently, developers copy code and imports break.

Avoid it by:

  • Keeping @/ strictly app-local.
  • Importing shared code only via packages like @acme/shared.

Pitfall 3: Environment variables leaking across apps#

A root .env can accidentally configure all apps with the same values.

Avoid it by:

  • Per-app .env.example and .env.local.
  • Documenting required keys in each app’s README.
  • Treating env vars as build inputs to prevent invalid cache hits.

Pitfall 4: Cache misses from non-deterministic steps#

Common culprits:

  • Generating files with timestamps during build.
  • Writing into folders not listed in Turbo outputs.
  • Network calls during build steps.

Make build tasks deterministic and declare all outputs.

💡 Tip: Add a “repro build” check in CI: run turbo run build twice and verify the second run is mostly cache hits. If not, your build is not deterministic or your outputs are incomplete.

Pitfall 5: Lint and typecheck inconsistencies across packages#

If packages run different linters or TS settings, the monorepo loses its main advantage.

Solve it by centralizing TS and ESLint configs, and enforcing them via Turbo scripts.

# Key Takeaways#

  • Use a monorepo when you have multiple Next.js apps that share UI, utilities, and configuration; otherwise keep it single-repo.
  • Prefer workspace packages like @acme/ui over cross-repo path aliases for cleaner boundaries and fewer bundler issues.
  • Configure turbo.json outputs and task dependencies carefully to get reliable cache hits in CI.
  • Treat environment variables as build inputs and keep them app-scoped to avoid incorrect builds and cache poisoning.
  • Use transpilePackages and React peerDependencies to prevent Next.js workspace import and duplicate React issues.
  • Invest in centralized linting and TypeScript configs early to prevent tooling drift as the repo grows.

# Conclusion#

A Next.js monorepo with Turborepo and pnpm workspaces can be a major productivity win when you have multiple apps and shared packages, but only if you design clear boundaries and treat caching, env vars, and tooling as first-class concerns.

If you want Samioda to help you set up a production monorepo, migrate multiple Next.js apps into a shared platform, or standardize CI with reliable caching, contact us and we’ll propose a concrete repo structure and rollout plan based on your apps and deployment targets.

FAQ

Share
A
Adrijan OmićevićFounder & Senior Developer

Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.