Set Up a Monorepo with Turborepo, Next.js, and NestJS
Complete guide to setting up a Turborepo monorepo with Next.js frontend and NestJS backend, including shared packages, TypeScript configs, and CI/CD pipelines.
Tags
Set Up a Monorepo with Turborepo, Next.js, and NestJS
TL;DR
Turborepo's task caching and dependency graph make it possible to run a Next.js frontend and NestJS backend in a single monorepo with shared TypeScript packages, cutting CI build times significantly through incremental builds and remote caching.
Prerequisites
- ›Node.js 18+ and pnpm 8+ installed
- ›Basic familiarity with Next.js and NestJS
- ›A GitHub account (for remote caching setup)
- ›Terminal/command line experience
Step 1: Initialize the Turborepo
Start by scaffolding a new Turborepo workspace. We use pnpm because it handles monorepo dependencies efficiently with its content-addressable storage.
npx create-turbo@latest my-monorepo
cd my-monorepoWhen prompted, select pnpm as the package manager. The scaffolded project includes an apps/ and packages/ directory. Remove the default apps since we will create our own.
rm -rf apps/web apps/docsYour root package.json should declare the workspace structure:
{
"name": "my-monorepo",
"private": true,
"scripts": {
"build": "turbo build",
"dev": "turbo dev",
"lint": "turbo lint",
"type-check": "turbo type-check"
},
"devDependencies": {
"turbo": "^2.0.0"
},
"packageManager": "pnpm@8.15.0"
}And your pnpm-workspace.yaml:
packages:
- "apps/*"
- "packages/*"Step 2: Create the Next.js Frontend
Create the Next.js application inside the apps/ directory.
cd apps
npx create-next-app@latest web --typescript --tailwind --app --src-dir --no-import-alias
cd ..Update apps/web/package.json to set the name and add workspace scripts:
{
"name": "@repo/web",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
}
}Step 3: Create the NestJS Backend
Now scaffold the NestJS API inside apps/.
cd apps
npx @nestjs/cli new api --package-manager pnpm --skip-git
cd ..Update apps/api/package.json:
{
"name": "@repo/api",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"start": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
"type-check": "tsc --noEmit"
}
}Verifying the Structure
At this point, your directory should look like this:
my-monorepo/
├── apps/
│ ├── web/ # Next.js frontend
│ └── api/ # NestJS backend
├── packages/ # Shared packages (empty for now)
├── turbo.json
├── pnpm-workspace.yaml
└── package.json
Step 4: Create the Shared Types Package
This is where the monorepo shines. Create a shared package for types, DTOs, and validation schemas that both apps consume.
mkdir -p packages/shared-types/srcCreate packages/shared-types/package.json:
{
"name": "@repo/shared-types",
"version": "0.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"type-check": "tsc --noEmit",
"lint": "eslint src/"
},
"devDependencies": {
"typescript": "^5.3.0"
}
}Create packages/shared-types/tsconfig.json:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}Now add your shared types:
// packages/shared-types/src/index.ts
export interface User {
id: string;
email: string;
name: string;
role: "admin" | "user";
createdAt: string;
}
export interface ApiResponse<T> {
data: T;
message: string;
statusCode: number;
}
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
total: number;
page: number;
limit: number;
hasMore: boolean;
}
export interface CreateUserDto {
email: string;
name: string;
password: string;
}
export interface LoginDto {
email: string;
password: string;
}Consuming Shared Types
Add the shared package as a dependency in both apps:
# In apps/web/package.json, add:
pnpm --filter @repo/web add @repo/shared-types --workspace
# In apps/api/package.json, add:
pnpm --filter @repo/api add @repo/shared-types --workspaceNow both apps can import from the same source:
// In apps/web/src/app/page.tsx
import type { User, ApiResponse } from "@repo/shared-types";
async function getUsers(): Promise<ApiResponse<User[]>> {
const res = await fetch("http://localhost:4000/users");
return res.json();
}// In apps/api/src/users/users.controller.ts
import { Controller, Get } from "@nestjs/common";
import type { User, ApiResponse } from "@repo/shared-types";
@Controller("users")
export class UsersController {
@Get()
findAll(): ApiResponse<User[]> {
return {
data: [],
message: "Users retrieved",
statusCode: 200,
};
}
}Step 5: Set Up Shared TypeScript Configuration
Create a base TypeScript config that all packages extend.
// tsconfig.base.json (root)
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"exclude": ["node_modules", "dist"]
}Each app extends this base config. For example, apps/web/tsconfig.json:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "preserve",
"module": "ESNext",
"moduleResolution": "bundler",
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}Step 6: Configure Turborepo Pipelines
The turbo.json file defines how tasks run and their dependency relationships.
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"]
},
"type-check": {
"dependsOn": ["^type-check"]
}
}
}Understanding the Pipeline
- ›
"dependsOn": ["^build"]means a package's build runs only after all of its workspace dependencies finish building. The^prefix indicates topological ordering. - ›
"outputs"tells Turborepo which files to cache. When inputs have not changed, these outputs are restored from cache instead of rebuilding. - ›
"persistent": truefordevkeeps the task running (watch mode) and disables caching since dev servers should not be cached.
Step 7: Add Development Scripts
Update the root package.json with useful development scripts:
{
"scripts": {
"dev": "turbo dev",
"dev:web": "turbo dev --filter=@repo/web",
"dev:api": "turbo dev --filter=@repo/api",
"build": "turbo build",
"lint": "turbo lint",
"type-check": "turbo type-check",
"clean": "turbo clean && rm -rf node_modules"
}
}Run pnpm dev to start both apps in parallel. Turborepo knows the dependency graph, so it starts shared-types first (if it had a build step), then launches both web and api concurrently.
pnpm dev
# Starts Next.js on port 3000 and NestJS on port 3000 (NestJS default)Step 8: Enable Remote Caching
Remote caching shares build artifacts across your team and CI. Connect to Vercel's remote cache:
npx turbo login
npx turbo linkOnce linked, any build artifacts cached locally are pushed to the remote cache. When a teammate or CI runner builds the same unchanged package, it pulls from the cache instead of rebuilding.
For CI pipelines, set the TURBO_TOKEN and TURBO_TEAM environment variables:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm type-check
- run: pnpm lint
- run: pnpm build
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}Putting It All Together
Your monorepo now has a clear architecture:
- ›apps/web -- Next.js frontend consuming shared types for API contracts
- ›apps/api -- NestJS backend using the same shared types for request/response validation
- ›packages/shared-types -- single source of truth for DTOs, interfaces, and contracts
- ›turbo.json -- orchestrates builds with dependency awareness and caching
Running pnpm build builds everything in the correct order. Running pnpm dev starts both apps in parallel. Changing a shared type triggers rebuilds only in the packages that depend on it.
Next Steps
- ›Add a shared validation package -- use Zod schemas in
packages/shared-validationthat both apps reference for runtime validation - ›Set up a shared UI package -- create
packages/uifor React components shared across multiple frontend apps - ›Add database packages -- put Prisma schemas in a shared package so migrations are centralized
- ›Configure environment variables -- use
dotenv-cliwith per-app.envfiles and Turborepo'sglobalDependencies - ›Add E2E tests -- create a
packages/e2eworkspace that runs Playwright tests against both apps
FAQ
Why use Turborepo instead of Nx for a monorepo?
Turborepo is simpler to configure and integrates seamlessly with the npm/pnpm workspace ecosystem. It excels at incremental builds and remote caching with minimal configuration, making it ideal for teams that want monorepo benefits without a steep learning curve.
How do you share TypeScript types between Next.js and NestJS?
Create a shared packages directory (e.g., packages/shared-types) with its own tsconfig and package.json. Both apps reference it as a workspace dependency, giving you a single source of truth for DTOs, API contracts, and validation schemas.
Does Turborepo support remote caching?
Yes. Turborepo supports remote caching through Vercel or self-hosted solutions. Once enabled, build artifacts are shared across team members and CI runs, so unchanged packages are never rebuilt twice.
Collaboration
Need help with a project?
Let's Build It
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.
Related Articles
How to Add Observability to a Node.js App with OpenTelemetry
Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.
How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js
A practical guide to building a Backend-for-Frontend with Next.js and Node.js for API aggregation, auth handling, caching, and frontend-specific data shaping.
How I Structure CI/CD for Next.js, Docker, and GitHub Actions
A practical CI/CD blueprint for Next.js apps using Docker and GitHub Actions, including testing, image builds, deployment stages, cache strategy, and release safety.