How to structure a Next.js monorepo using Turborepo — workspace setup, shared packages, TypeScript configuration, CI caching, and the decisions that matter when the repo scales beyond one team.
Monorepos are the right architecture for most multi-package JavaScript projects. Turborepo makes them fast.
This guide covers a production-ready setup: workspace structure, shared packages, TypeScript configuration, CI with remote caching, and the configuration decisions that matter as the repo grows.
A monorepo without a build system is a slow monorepo. Running tsc and next build across ten packages serially is slower than running them across separate repos. Turborepo fixes this with two things:
Task parallelisation. Tasks that don't depend on each other run simultaneously. If building web and docs don't depend on each other, they build in parallel.
Caching. Tasks that haven't changed don't re-run. Their output is served from cache — locally or from a remote cache shared across the whole team and CI.
The result: a 10-package monorepo that naively takes 8 minutes to build runs in 90 seconds with Turborepo after the first full build.
my-project/
├── apps/
│ ├── web/ # Next.js main application
│ └── docs/ # Next.js docs or marketing site
├── packages/
│ ├── ui/ # Shared React components
│ ├── typescript-config/ # Shared tsconfig files
│ └── eslint-config/ # Shared ESLint config
├── package.json
├── pnpm-workspace.yaml
└── turbo.json
apps/ contains deployable applications — things that run as a service.
packages/ contains shared code — things that apps import.
node --version # 18+
pnpm --version # 8+Use pnpm. The disk efficiency and workspace linking is significantly better than npm or yarn for monorepos.
mkdir my-project && cd my-project
pnpm initCreate pnpm-workspace.yaml:
packages:
- "apps/*"
- "packages/*"Install Turborepo:
pnpm add -D turbo --workspace-rootturbo.json at the root:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"]
},
"type-check": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
}
}
}Key points:
"dependsOn": ["^build"] means "build all dependencies first." The ^ prefix means "in dependent packages.""inputs" tells Turbo what files affect this task's cache key. Include .env* if env vars affect output."outputs" tells Turbo what to cache. .next/cache/** is excluded because it's Next.js's own build cache, not output.Create packages/typescript-config/ with:
package.json:
{
"name": "@repo/typescript-config",
"version": "0.0.0",
"private": true,
"files": ["*.json"]
}base.json:
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Default",
"compilerOptions": {
"strict": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"moduleResolution": "bundler",
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}nextjs.json:
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Next.js",
"extends": "./base.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"lib": ["dom", "dom.iterable", "esnext"],
"module": "ESNext",
"jsx": "preserve",
"incremental": true
},
"exclude": ["node_modules"]
}Use it in your Next.js app's tsconfig.json:
{
"extends": "@repo/typescript-config/nextjs.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}packages/ui/package.json:
{
"name": "@repo/ui",
"version": "0.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"react": "^18.0.0"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"typescript": "^5.0.0"
}
}Important: export from source (./src/index.ts), not from a build output. Next.js transpiles imported packages natively — no separate build step required for the UI package.
In your Next.js next.config.ts:
const nextConfig = {
transpilePackages: ["@repo/ui"],
};{
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"type-check": "turbo run type-check",
"test": "turbo run test",
"clean": "turbo run clean && rm -rf node_modules"
}
}Turborepo's remote cache is the biggest velocity multiplier for teams. Without it, every CI run starts cold. With it, CI reuses cached task outputs from previous runs — locally or by other developers.
Vercel provides remote caching for free. Get a token:
npx turbo login
npx turbo linkIn your GitHub Actions workflow:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm build
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
- run: pnpm lint
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
- run: pnpm type-check
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}Add TURBO_TOKEN and TURBO_TEAM to your GitHub repository secrets.
Circular dependencies. apps/web importing from apps/docs is a circular dependency. Apps should only import from packages/. If two apps need the same code, it belongs in a package.
Building packages separately. If your UI package has its own build script that outputs to dist/, and your Next.js app imports from dist/, you need to rebuild the package every time you change it. Exporting from source and using transpilePackages avoids this entirely.
Not pinning Turborepo version. Turbo's caching is sensitive to version changes. Pin it: "turbo": "2.x.x" in your root devDependencies.
Missing outputs in turbo.json. If you don't declare outputs, Turbo can't restore them from cache. Every non-declared output re-runs even when inputs haven't changed.
This is the monorepo setup we use at Hunchbite across all our projects. The combination of Turborepo remote caching, shared TypeScript config, and source-exported packages eliminates the build overhead that slows most monorepos down.
If you're setting this up and running into specific issues, or if you want this kind of infrastructure installed across an existing codebase — get in touch.
If this guide resonated with your situation, let's talk. We offer a free 30-minute discovery call — no pitch, just honest advice on your specific project.
How to set up Drizzle ORM with PostgreSQL from scratch — schema definition, migrations, query patterns, connection pooling, and the configuration decisions that matter in production Next.js applications.
11 min readguideA technical guide to database indexes: B-tree internals, composite index column ordering, covering indexes, partial indexes, the write cost of over-indexing, EXPLAIN ANALYZE interpretation, and the common indexing mistakes that degrade production performance.
14 min read