Managing multiple Next.js applications in a single repository can feel like herding cats. Without a deliberate structure, shared code becomes duplicated, dependency versions drift apart, and developer experience suffers. A well-architected monorepo solves all of these problems and unlocks a level of consistency that is difficult to achieve otherwise.
This article walks through a practical, production-ready approach to structuring a Next.js monorepo using Turborepo and pnpm workspaces, two tools that have become the de facto standard for this kind of setup.
Why a Monorepo?
Before diving into the structure, it is worth clarifying what a monorepo actually buys you:
- Shared code without publishing: UI components, utility functions, and TypeScript types can be consumed across apps without going through a registry.
- Atomic commits: A single pull request can update a shared library and all consuming apps simultaneously.
- Unified tooling: ESLint, Prettier, TypeScript, and testing configurations live in one place and propagate everywhere.
- Faster CI with caching: Tools like Turborepo cache task outputs so unchanged packages are never rebuilt.
A monorepo is not always the right choice. If your applications are entirely unrelated or owned by separate teams with no shared code, independent repositories may serve you better. But for most product companies with multiple frontend surfaces, a monorepo pays dividends quickly.
Choosing Your Tools
Package Manager: pnpm
pnpm is the preferred package manager for monorepos because of its efficient disk usage (content-addressable storage), strict dependency resolution that avoids phantom dependencies, and first-class workspace support.
Build Orchestrator: Turborepo
Turborepo sits on top of your package manager and orchestrates tasks across packages. Its remote caching feature means that CI pipelines share a build cache, so tasks that were already completed on another developer's machine or a previous pipeline run are skipped entirely.
Repository Structure
A clean monorepo separates concerns into three top-level directories: apps, packages, and configuration files at the root.
my-monorepo/
├── apps/
│ ├── web/ # Marketing site (Next.js)
│ ├── dashboard/ # Internal dashboard (Next.js)
│ └── docs/ # Documentation site (Next.js)
├── packages/
│ ├── ui/ # Shared component library
│ ├── config-typescript/ # Shared tsconfig presets
│ ├── config-eslint/ # Shared ESLint configs
│ └── utils/ # Shared utility functions
├── turbo.json
├── package.json
└── pnpm-workspace.yaml
The apps/ Directory
Each folder inside apps/ is a standalone Next.js application with its own package.json, next.config.js, and everything else a Next.js app normally needs. The key difference is that these apps can import from packages/ as if they were any other dependency.
The packages/ Directory
This is where shared code lives. Each package is a small, focused module with its own package.json. They are not published to npm; they are consumed directly through workspace links.
Setting Up pnpm Workspaces
At the root of the repository, create a pnpm-workspace.yaml file that tells pnpm which directories contain packages:
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
The root package.json should mark itself as private and define scripts that delegate to Turborepo:
{
"name": "my-monorepo",
"private": true,
"scripts": {
"build": "turbo build",
"dev": "turbo dev",
"lint": "turbo lint",
"typecheck": "turbo typecheck"
},
"devDependencies": {
"turbo": "^2.0.0"
},
"packageManager": "pnpm@9.0.0"
}
Configuring Turborepo
The turbo.json file at the root defines the task pipeline. It tells Turborepo how tasks relate to each other and which outputs to cache.
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"]
},
"typecheck": {
"dependsOn": ["^build"]
}
}
}
The ^build syntax means "run build in all dependencies first." This ensures that if apps/dashboard depends on packages/ui, the UI package is built before the dashboard attempts to compile.
Creating a Shared Package
Let's walk through setting up packages/ui as a shared component library.
packages/ui/package.json
{
"name": "@repo/ui",
"version": "0.0.1",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"devDependencies": {
"typescript": "^5.0.0"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
}
Note: Exporting TypeScript source directly (rather than a compiled
dist/) is a common pattern in Next.js monorepos. Next.js can transpile packages natively using thetranspilePackagesoption, which eliminates a separate build step for internal packages during development.
packages/ui/src/index.ts
export { Button } from "./components/Button";
export { Card } from "./components/Card";
export type { ButtonProps } from "./components/Button";
Consuming the Package in an App
Inside apps/dashboard/package.json, add the shared package as a dependency using the workspace protocol:
{
"dependencies": {
"@repo/ui": "workspace:*"
}
}
Then enable transpilation in apps/dashboard/next.config.js:
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@repo/ui"],
};
module.exports = nextConfig;
Now components from @repo/ui can be imported directly in any file inside the dashboard app:
import { Button } from "@repo/ui";
Sharing TypeScript Configuration
Rather than duplicating tsconfig.json across every app and package, create a shared base configuration in packages/config-typescript.
packages/config-typescript/base.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
},
"exclude": ["node_modules"]
}
Each app or package then extends this base:
apps/dashboard/tsconfig.json
{
"extends": "@repo/config-typescript/base.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
Sharing ESLint Configuration
The same pattern applies to ESLint. Create a packages/config-eslint package with a base config and framework-specific extensions.
packages/config-eslint/next.js
const { resolve } = require("node:path");
const project = resolve(process.cwd(), "tsconfig.json");
/** @type {import("eslint").Linter.Config} */
module.exports = {
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"next/core-web-vitals",
],
settings: {
"import/resolver": {
typescript: { project },
},
},
rules: {
"react/react-in-jsx-scope": "off",
},
};
Each Next.js app then references it in its own .eslintrc.js:
/** @type {import("eslint").Linter.Config} */
module.exports = {
extends: [require.resolve("@repo/config-eslint/next")],
parserOptions: {
project: true,
},
};
Running Tasks
With everything wired up, Turborepo handles orchestration. From the root directory:
# Start all apps in development mode
pnpm dev
# Build all apps and packages (respecting dependency order)
pnpm build
# Build only the dashboard app and its dependencies
pnpm build --filter=dashboard
# Run lint across the entire repo
pnpm lint
# Add a dependency to a specific app
pnpm add zod --filter=dashboard
The --filter flag is one of Turborepo's most useful features. It lets you scope any command to a specific app or package, which is essential when you do not want to rebuild everything during local development.
Tips for Keeping the Monorepo Healthy
Keep packages small and focused. A utils package that grows to contain hundreds of unrelated functions becomes a liability. Prefer splitting into domain-specific packages like utils-date, utils-format, or utils-api.
Enforce boundaries. Use ESLint rules or Turborepo's boundaries feature to prevent packages/ui from importing from apps/dashboard. Packages should never depend on apps.
Version internal packages consistently. Using workspace:* for all internal packages ensures that your workspace always resolves to the local version rather than a published one.
Enable remote caching early. Turborepo's remote cache can be connected to Vercel or a self-hosted option. Setting it up before your team grows means everyone benefits from shared build caches from day one.
Document the structure. A short CONTRIBUTING.md at the root explaining where to add new apps, how to create packages, and how to run tasks saves new team members hours of confusion.
A well-structured Next.js monorepo eliminates the overhead of managing multiple repositories while giving every app access to a consistent set of shared building blocks. The combination of pnpm workspaces for dependency management and Turborepo for task orchestration handles the hard parts automatically, letting your team focus on shipping features rather than managing infrastructure.
Start with the structure outlined here, add packages as shared needs emerge, and lean on Turborepo's caching to keep your build times fast as the repository scales.