# 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#
| Requirement | Version | Notes |
|---|---|---|
| Node.js | 20+ | Use an LTS version for predictable CI behavior |
| pnpm | 9+ | Workspace support and efficient disk usage |
| GitHub Actions or similar CI | — | Examples use GitHub Actions concepts |
| Next.js | 14 or 15 | Works with both, examples assume App Router patterns |
| TypeScript | 5+ | Strongly recommended for shared packages |
# Repo Structure (Apps + Packages)#
A practical Turborepo structure separates deployable apps from shared packages:
| Path | Type | What goes here |
|---|---|---|
apps/web | Next.js app | Main customer-facing web app |
apps/admin | Next.js app | Internal admin dashboard |
packages/ui | Shared package | Design system components |
packages/config | Shared package | ESLint, TS config, Prettier config |
packages/shared | Shared package | Utilities, types, API clients |
tooling | Optional | Scripts, generators, dev utilities |
This structure supports independent deployments per app, while keeping shared code versioned together.
ℹ️ Note: Keeping config in a package like
packages/configavoids 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.
mkdir nextjs-monorepo
cd nextjs-monorepo
pnpm init
pnpm add -D turboCreate pnpm-workspace.yaml at the repo root:
packages:
- "apps/*"
- "packages/*"Update package.json scripts at the root to control all tasks:
{
"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:
{
"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:
mkdir -p apps/web apps/admin packages/ui packages/shared packages/configInside each app, you want consistent scripts. Example apps/web/package.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:
{
"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:
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:
{
"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:
export { Button } from "./components/Button";Create packages/ui/src/components/Button.tsx:
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:
{
"dependencies": {
"@acme/shared": "workspace:*",
"@acme/ui": "workspace:*"
}
}Then use them in app code, for example in a page:
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:
{
"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:
{
"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:
module.exports = {
root: false,
extends: ["next/core-web-vitals"],
rules: {
"no-console": ["warn", { allow: ["warn", "error"] }]
}
};In apps/web/.eslintrc.cjs:
module.exports = {
extends: ["../../packages/config/eslint-nextjs.cjs"]
};⚠️ Warning: Do not set
root: truein 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.
Recommended approach#
- Use package imports for shared code:
@acme/ui,@acme/shared. - Keep app-local aliases simple:
@/componentsinside each app only.
In apps/web/tsconfig.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:
/** @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:
| Layer | Tooling | Goal |
|---|---|---|
| Unit tests | Vitest | Fast feedback for pure logic and components |
| Integration tests | Vitest plus MSW | API contract confidence without hitting real services |
| E2E tests | Playwright | Full 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.localper app for developer overrides. - Keep
.env.exampleper app to document required keys. - Never rely on root
.envto configure multiple apps unless you have a strict naming convention.
| Scenario | Recommended |
|---|---|
| Shared key across apps | Use same name, but store values per app environment in hosting platform |
| App-specific key | Prefix with app name, for example WEB_SOME_KEY, ADMIN_SOME_KEY |
| Public keys in Next.js | Use 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=webturbo 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.
| Aspect | Benefit | Cost |
|---|---|---|
| Shared code | One source of truth, fewer duplicated fixes | Stronger coupling between apps |
| Tooling | Unified lint, TS, test strategy | More upfront setup, more “platform” work |
| CI | Caching and affected builds can be very fast | Misconfigured caching causes confusing failures |
| Dependencies | One place to upgrade versions | Dependency conflicts affect more projects |
| Developer experience | Easier cross-app changes | Repo 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:
- 1Add
transpilePackagesfor workspace packages. - 2Use
peerDependenciesfor React in UI packages. - 3Ensure 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.exampleand.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 buildtwice 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/uiover cross-repo path aliases for cleaner boundaries and fewer bundler issues. - Configure
turbo.jsonoutputs 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
transpilePackagesand ReactpeerDependenciesto 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
Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.
More in Web Development
All →Next.js Authorization in the App Router: RBAC vs ABAC with Middleware, RLS, and Policy Patterns
A practical guide to Next.js authorization in the App Router using RBAC and ABAC — with middleware checks, server component guards, database-enforced RLS, decision matrix, and copy-paste policy patterns.
React Testing Strategy in 2026: Vitest + React Testing Library + MSW for Confident Releases
A pragmatic React testing strategy for 2026 using Vitest, React Testing Library, and MSW. Learn a realistic test pyramid, reduce flakiness, and ship confidently with CI-ready patterns.
Modern React Frontend Architecture: Feature-Based Modules, Boundaries, and Scalability
A practical guide to React frontend architecture feature-based modules: clear boundaries, shared layers, dependency rules, and a maintainable folder strategy for React and Next.js.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
Building a Design System in Next.js with Radix UI, Tailwind, and Storybook: End-to-End Guide for 2026
A practical, production-ready approach to building and maintaining a Next.js design system using Radix UI for accessibility, Tailwind for styling, and Storybook for documentation, testing, and versioned releases.
Building a React Design System with Design Tokens: Tailwind CSS + Radix UI + TypeScript
A practical 2026 guide to building a React design system with Tailwind and Radix using design tokens, accessible primitives, theming, and reusable packages across multiple apps.
React Forms at Scale: React Hook Form + Zod Patterns for Complex Products
React forms best practices for large apps using React Hook Form and Zod: schema-first validation, reusable fields, async checks, multi-step flows, performance, accessibility, and server/API integration patterns.