Back to Blog

How to Structure a SaaS Monorepo With NestJS Backend and Next.js Frontend

Published: 2026-06-23
How to Structure a SaaS Monorepo With NestJS Backend and Next.js Frontend

We spent six months jumping between GitHub tabs on our first SaaS project. The backend was one repo. The frontend was another. The shared TypeScript types — the ones that should have been identical — drifted apart because nobody remembered to update both repos when an API response changed. We found the mismatch the way you always find it: a production error that should have been caught at compile time.

That is the case for a monorepo in one sentence: it turns runtime type errors into compile-time errors by keeping shared code actually shared.

Here is the exact saas monorepo nestjs nextjs structure we use now, built with Turborepo. It keeps the backend and frontend in lockstep, shares types and validation without a deployment pipeline, and makes CI faster — not slower — than separate repos.

SaaS monorepo NestJS Next.js structure — close-up of code and file organization in a software development environment on a monitor

Monorepo vs Polyrepo: The Tradeoff That Matters for Small Teams

The standard argument against monorepos is that they do not scale. Google and Meta use monorepos, and look at the tooling they need.

That argument is about teams that are not you.

For a two-to-ten-developer SaaS team, the polyrepo experience is: you make a change to an API response shape and now you have two PRs, two CI runs, two deploys, and a coordination step to make sure they land in the right order. You publish an npm package for shared types — or you copy-paste the interface file and hope someone remembers to sync it. (Nobody remembers.)

A monorepo eliminates all of that. One PR, one CI run, one deploy (or one deploy per app, independently, from the same commit). The types are in the same repository. When you change a backend response shape, TypeScript tells you immediately which frontend component needs updating.

The tradeoff is real but manageable: a monorepo needs a build orchestrator (Turborepo), and you need CI discipline to avoid rebuilding everything on every commit. We cover both below.

Why Turborepo Instead of Nx or Lerna

Turborepo wins for this stack for three specific reasons:

  1. Task orchestration, not just builds. Turborepo knows that apps/web depends on packages/shared, which depends on packages/tsconfig. It runs tasks in the correct order and caches every output.
  2. Remote caching. Once a package is built in CI, every developer and every subsequent CI run pulls the cached artifact instead of rebuilding. For a shared types package that rarely changes, this is nearly free.
  3. Zero config for most setups. The turbo.json pipeline definition is about 20 lines.

We tried Nx on a previous project and found its abstraction layer — wrappers around everything — created more friction than it solved. Turborepo is more explicit. You define what build, test, and lint mean per package, and it runs them.

SaaS Monorepo NestJS Next.js Structure: apps/ and packages/

Here is the directory layout we use for every SaaS client project now:

Code
1saas-app/
2├── apps/
3│   ├── api/               # NestJS application
4│   │   ├── src/
5│   │   ├── test/
6│   │   ├── tsconfig.json
7│   │   └── package.json
8│   └── web/               # Next.js application
9│       ├── src/
10│       ├── public/
11│       ├── tsconfig.json
12│       └── package.json
13├── packages/
14│   ├── shared/            # TypeScript types and interfaces
15│   │   ├── src/
16│   │   ├── tsconfig.json
17│   │   └── package.json
18│   ├── validation/        # Zod schemas (used by both NestJS and Next.js)
19│   │   ├── src/
20│   │   ├── tsconfig.json
21│   │   └── package.json
22│   ├── ui/                # Shared React component library
23│   │   ├── src/
24│   │   ├── tsconfig.json
25│   │   └── package.json
26│   ├── eslint-config/     # Shared ESLint configuration
27│   │   └── package.json
28│   └── tsconfig/          # Shared TypeScript configuration
29│       └── package.json
30├── turbo.json
31├── pnpm-workspace.yaml
32├── package.json
33└── .github/
34    └── workflows/
35        └── ci.yml

Key principle: apps/ contains deployable applications, packages/ contains libraries that applications and other packages import. No package ever imports directly from another application's source directory — everything goes through the workspace dependency. For guidance on organizing the NestJS API app specifically within the monorepo, see our NestJS project structure guide.

Configuring the Workspace

The workspace protocol (workspace:*) tells pnpm to resolve these packages locally instead of reaching for an npm registry. Here is the pnpm workspace documentation for the full configuration reference.

YAML
1# pnpm-workspace.yaml
2packages:
3  - "apps/*"
4  - "packages/*"
JSON
1// turbo.json
2{
3  "$schema": "https://turbo.build/schema.json",
4  "tasks": {
5    "build": {
6      "dependsOn": ["^build"],
7      "outputs": [".next/**", "dist/**"]
8    },
9    "test": {
10      "dependsOn": ["^build"],
11      "outputs": []
12    },
13    "lint": {
14      "outputs": []
15    },
16    "dev": {
17      "cache": false,
18      "persistent": true
19    }
20  }
21}

The "dependsOn": ["^build"] directive tells Turborepo: before building this package, build all of its workspace dependencies first. When you run turbo build, it builds packages/tsconfig, then packages/eslint-config, then packages/shared, then packages/validation, then apps/api and apps/web in parallel.

SaaS monorepo NestJS Next.js structure — colorful sticky notes with folders representing a well-organized project structure and planning

Shared Package: TypeScript Types

Two developers sharing TypeScript types across the backend and frontend of a SaaS monorepo

The highest-ROI shared package in any NestJS + Next.js monorepo is the types package. It keeps API contracts synchronized without documentation or Slack messages.

TypeScript
1// packages/shared/src/types/user.ts
2export interface UserResponse {
3  id: string;
4  email: string;
5  name: string;
6  plan: 'free' | 'pro' | 'enterprise';
7  createdAt: string;
8}
9
10export interface CreateUserRequest {
11  email: string;
12  name: string;
13  plan?: 'free' | 'pro' | 'enterprise';
14}
15
16// packages/shared/src/index.ts
17export * from './types/user';
18export * from './types/subscription';
19export * from './types/organization';

Both the NestJS backend and the Next.js frontend import from @saas/shared:

TypeScript
1// apps/api/src/users/users.controller.ts
2import { UserResponse, CreateUserRequest } from '@saas/shared';
3import { Controller, Post, Body } from '@nestjs/common';
4
5@Controller('users')
6export class UsersController {
7  @Post()
8  async create(@Body() dto: CreateUserRequest): Promise<UserResponse> {
9    // ...
10  }
11}
TypeScript
1// apps/web/src/app/users/page.tsx
2import { UserResponse } from '@saas/shared';
3
4export default async function UsersPage() {
5  const users: UserResponse[] = await fetch('/api/users').then(r => r.json());
6  // TypeScript catches mismatches here at compile time
7}

The dependency is declared in each package.json using pnpm's workspace protocol:

JSON
1// apps/api/package.json
2{
3  "dependencies": {
4    "@saas/shared": "workspace:*"
5  }
6}

Shared Package: Validation Schemas with Zod

Zod schemas are the second shared package that pays for itself immediately. Define the schema once, use it on both sides:

TypeScript
1// packages/validation/src/schemas/user.ts
2import { z } from 'zod';
3
4export const createUserSchema = z.object({
5  email: z.string().email(),
6  name: z.string().min(2).max(100),
7  plan: z.enum(['free', 'pro', 'enterprise']).default('free'),
8});
9
10export type CreateUserInput = z.infer<typeof createUserSchema>;

In the NestJS backend, use it with a ZodValidationPipe:

TypeScript
1// apps/api/src/common/pipes/zod-validation.pipe.ts
2import { PipeTransform, BadRequestException } from '@nestjs/common';
3import { ZodSchema } from 'zod';
4
5export class ZodValidationPipe implements PipeTransform {
6  constructor(private schema: ZodSchema) {}
7
8  transform(value: unknown) {
9    const result = this.schema.safeParse(value);
10    if (!result.success) {
11      throw new BadRequestException(result.error.format());
12    }
13    return result.data;
14  }
15}

In the Next.js frontend, use the same schema for client-side validation:

TypeScript
1// apps/web/src/app/register/page.tsx
2'use client';
3import { createUserSchema } from '@saas/validation';
4import { useState } from 'react';
5
6export default function RegisterPage() {
7  const [errors, setErrors] = useState<Record<string, string[]>>({});
8
9  const handleSubmit = (formData: FormData) => {
10    const result = createUserSchema.safeParse({
11      email: formData.get('email'),
12      name: formData.get('name'),
13      plan: formData.get('plan'),
14    });
15
16    if (!result.success) {
17      setErrors(result.error.flatten().fieldErrors as Record<string, string[]>);
18      return;
19    }
20
21    // Submit result.data to the API
22  };
23}

One schema, two consumers, zero drift. When you add a field to the registration form, you change it in one file and both sides update.

Configuring TypeScript Paths for Cross-Package Imports

TypeScript needs to know how to resolve @saas/shared and @saas/validation. A root tsconfig.json handles this:

JSON
1// tsconfig.json (root)
2{
3  "compilerOptions": {
4    "target": "ES2022",
5    "module": "ESNext",
6    "moduleResolution": "bundler",
7    "strict": true,
8    "paths": {
9      "@saas/shared": ["./packages/shared/src"],
10      "@saas/shared/*": ["./packages/shared/src/*"],
11      "@saas/validation": ["./packages/validation/src"],
12      "@saas/validation/*": ["./packages/validation/src/*"],
13      "@saas/ui": ["./packages/ui/src"],
14      "@saas/ui/*": ["./packages/ui/src/*"]
15    }
16  }
17}

Each application extends the root config and overrides its own compilerOptions:

JSON
1// apps/api/tsconfig.json
2{
3  "extends": "../../tsconfig.json",
4  "compilerOptions": {
5    "outDir": "./dist",
6    "baseUrl": "."
7  }
8}

Turborepo does not use these paths at runtime — pnpm workspace resolution handles that. But the paths give your IDE the same resolution logic, so autocomplete and go-to-definition work across packages without opening the compiled output.

Running Both Apps in Development with One Command

Bash
1# Run both apps in parallel
2pnpm dev
3
4# Run a specific app
5pnpm dev --filter=api
6pnpm dev --filter=web

The turbo.json "dev" task uses "persistent": true because dev servers do not exit. Turborepo runs them concurrently and streams their output:

Code
1api: [Nest] LOG 1.0.0  Server running on http://localhost:4000
2web: ▲ Next.js 15.0.0
3web: - Local: http://localhost:3000

The NestJS app runs on port 4000 by default to avoid conflict with Next.js. Configure it in main.ts:

TypeScript
1async function bootstrap() {
2  const app = await NestFactory.create(AppModule);
3  app.setGlobalPrefix('api');
4  await app.listen(4000);
5}

Bootstrapping the Monorepo from Scratch

To initialize this structure on a new project:

Bash
1# Create the Turborepo project
2npx create-turbo@latest saas-app
3cd saas-app
4
5# Add NestJS backend
6pnpm add -D @nestjs/cli --filter=api
7pnpm exec nest new apps/api --package-manager pnpm --skip-git
8
9# Add shared packages
10mkdir -p packages/shared/src
11mkdir -p packages/validation/src
12mkdir -p packages/ui/src
13
14# Install Zod in the validation package
15pnpm --filter=@saas/validation add zod
16
17# Add workspace dependencies
18pnpm --filter=api add @saas/shared@workspace:* @saas/validation@workspace:*
19pnpm --filter=web add @saas/shared@workspace:* @saas/validation@workspace:*

The create-turbo scaffold gives you a working monorepo with pnpm workspaces configured, a turbo.json pipeline, and example packages you can delete and replace with your own. NestJS also supports its own monorepo mode via the CLI if you prefer a single-app approach, but Turborepo gives you better caching and multi-app orchestration out of the box. The NestJS CLI's --skip-git flag is critical here — you want Turborepo's root Git repository, not a nested one.

After bootstrapping, remove the example packages that create-turbo adds and wire up your turbo.json with the task definitions from the next section.

CI/CD: Only Rebuild What Changed

Version control and CI tooling rebuilding only the changed packages in a SaaS monorepo

The biggest concern developers raise about monorepos is CI time. If every commit rebuilds the entire project, a monorepo is slower than separate repos.

Turborepo's caching solves this. In CI:

YAML
1# .github/workflows/ci.yml
2name: CI
3on: [pull_request]
4
5jobs:
6  build:
7    runs-on: ubuntu-latest
8    steps:
9      - uses: actions/checkout@v4
10      - uses: pnpm/action-setup@v4
11      - uses: actions/setup-node@v4
12        with:
13          node-version: 22
14          cache: 'pnpm'
15
16      - run: pnpm install
17
18      # Turbo cache from remote (Vercel or self-hosted)
19      - run: pnpm turbo build --remote-only
20        env:
21          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
22          TURBO_TEAM: ${{ vars.TURBO_TEAM }}
23
24      - run: pnpm turbo lint test

With remote caching enabled, a PR that only changes the Next.js frontend takes roughly the time to build the frontend package — the backend and shared packages are pulled from cache. Pair this with feature-flag-based deployments to control which changes are visible to users without coupling to the deploy schedule.

The filter flag makes this even more precise:

Bash
1# Only build packages changed in this branch vs main
2pnpm turbo build --filter=[main...HEAD]

For teams that do not want Vercel's remote cache, Turborepo supports self-hosted caching via an S3-compatible bucket or local GitHub Actions cache.

When a Monorepo Becomes a Problem (and How to Split It)

Monorepos are not a permanent commitment. They work well up to a certain complexity threshold, and then they start to hurt. Recognizing that threshold is the skill.

Signs the monorepo is straining:

  1. CI exceeds 15 minutes even with caching
  2. A single PR touches 10+ packages because boundaries are not enforced
  3. Teams cannot deploy independently — every deploy ships every service
  4. The root package.json has more than 50 dependencies
  5. New developers need more than a day to understand the dependency graph

When to split, and how:

Split when the pain is in the deploy process, not the code. If teams want to ship at different cadences — the API team deploys three times a day, the frontend team deploys once a week — that is a deploy-boundary problem, not a code-organization problem.

Use the extract pattern: move one application or package into its own repository, set up its CI pipeline, and publish its shared types to an npm registry (or keep consuming them via git submodule in the short term).

We scoped a Java monolith migration at 8 months once. It took 14. The reason was the same thing that kills overgrown monorepos: too many moving parts changing at once. The Strangler Fig pattern saved that project — extract one module at a time, ship it independently, repeat. The same pattern works for splitting a monorepo. Do not attempt a big-bang split. Extract one app, validate the pipeline, then extract the next.

Conclusion

A monorepo with Turborepo, NestJS, and Next.js removes an entire class of coordination problems for small-to-medium SaaS teams. Shared types eliminate drift. Shared Zod schemas eliminate redundant validation logic. Cached CI pipelines eliminate the "rebuild everything" tax.

The structure is simple: apps/api, apps/web, packages/shared, packages/validation, packages/ui. The configuration is about 30 lines across turbo.json, pnpm-workspace.yaml, and the root tsconfig.json. The ROI starts on day one, when the first API change breaks a frontend type at compile time instead of in production.

Monorepo vs separate repos is not a religious debate for this stack. It is a practical decision that, for most early-stage SaaS teams, has a clear answer: start together, split later if you need to. The patterns above make that split possible without rewriting your entire project structure.

If you are setting up a SaaS monorepo and want a second pair of eyes on the package boundaries before you commit, get in touch — it is the kind of decision we would rather help you get right on day one than untangle in month six.

If you are setting up a monorepo right now and wondering whether your package boundaries are right — they probably are. The mistake is not the layout. It is drifting shared code that you promised to keep in sync and forgot about. Turborepo cannot fix that. But it can make the compile-time error the first signal instead of the production incident.

Frequently Asked Questions

A monorepo is a single Git repository that contains both your NestJS backend and Next.js frontend, plus shared packages for types, validation schemas, and utilities. Tools like Turborepo manage the build pipeline so you can develop, test, and deploy both apps from one codebase with shared configurations.

Start with a monorepo if your team has fewer than ten developers. You share TypeScript types, Zod validation schemas, and ESLint configs without publishing packages or dealing with cross-repo versioning. Switch to separate repos when the monorepo grows past roughly 10 active services, when CI pipelines slow down, or when teams need independent deploy cadences.

Create a Turborepo project with npx create-turbo@latest, add NestJS in apps/api, keep the Next.js app in apps/web, and create shared packages in packages/ for types, validation, and configuration. Define task dependencies in turbo.json so builds and tests run in the correct order across packages.

Shared packages typically include TypeScript types shared between backend and frontend (entity types, DTOs, API contracts), Zod validation schemas used in both NestJS pipes and Next.js forms, ESLint and TypeScript configurations, and a UI component library if you have multiple frontend apps.

Turborepo's caching ensures only changed packages and their dependents are rebuilt. In CI, turbo run build --filter=[commit-range] builds only what changed since the last commit. This makes monorepo CI faster than separate repos for most medium-sized projects, since shared dependencies are cached and reused across builds.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio