Code on a computer screen representing backend development
Backend Featured

Scaling Enterprise Backends with NestJS and Nx Monorepos

Discover how combining NestJS with Nx monorepos allows for highly scalable, modular, and easily maintainable backend architectures for modern web applications.

Protize Team
#nestjs #nx #monorepo #backend #architecture #javascript

Managing complex backends can quickly become a headache as your application grows. What starts as a clean codebase slowly turns into a tangled mess of duplicated types, inconsistent tooling, and CI pipelines that take 20 minutes to run even for a one-line change. Teams slow down. Bugs increase. Onboarding new developers takes weeks.

The solution is a Monorepo combined with NestJS — a pairing that lets you build a highly structured, shareable, and scalable foundation. In this blog, we’ll set up an enterprise-grade workspace from scratch and walk through the patterns that make it shine.


What Is a Monorepo?

A monorepo is a single Git repository that contains multiple projects — your backend services, frontend apps, shared libraries, CLI tools, and more. This is different from:

  • Monolith: One repo, one deployable unit
  • Polyrepo (Multi-repo): One repo per service (the common “microservices” approach)
  • Monorepo: One repo, many independently deployable units

Monorepo Advantages

ConcernPolyrepoMonorepo
Shared types/DTOsCopy-paste or npm publishDirect import
Atomic changesMultiple PRsOne PR
CI/CD speedRebuild everythingOnly rebuild what changed
ConsistencyEach repo has its own configUnified tooling
Developer onboardingClone N reposClone 1 repo

Nx is the tool that makes monorepos practical. It manages the project graph, orchestrates builds, and — most importantly — only runs tasks for projects affected by your changes.


Setting Up the Nx Workspace

# Create a new Nx workspace
npx create-nx-workspace@latest my-enterprise --preset=empty --packageManager=npm
cd my-enterprise

# Install plugins for NestJS and Next.js
npm install -D @nx/nest @nx/next @nx/js

# Generate a NestJS application
nx generate @nx/nest:application api --directory=apps/api

# Generate a Next.js frontend
nx generate @nx/next:application web --directory=apps/web --style=tailwind

Your workspace now looks like:

my-enterprise/
├── apps/
│   ├── api/                # NestJS backend
│   │   ├── src/
│   │   │   ├── app/
│   │   │   └── main.ts
│   │   └── project.json
│   └── web/                # Next.js frontend
│       ├── src/
│       └── project.json
├── libs/                   # Shared code lives here
├── nx.json                 # Nx configuration
├── tsconfig.base.json      # Shared TypeScript config
└── package.json            # Single package.json for the entire repo

Creating Shared Libraries

This is where the monorepo truly shines. Any code that multiple apps need — TypeScript interfaces, DTOs, validation logic, utility functions — lives in a shared library and is imported directly.

Shared DTOs Library

nx generate @nx/js:library shared-dto --directory=libs/shared/dto --importPath=@my-enterprise/shared-dto
// libs/shared/dto/src/lib/create-post.dto.ts
import { IsString, IsNotEmpty, MinLength, IsOptional, IsArray } from 'class-validator';

export class CreatePostDto {
  @IsString()
  @IsNotEmpty()
  @MinLength(5)
  title: string;

  @IsString()
  @IsNotEmpty()
  content: string;

  @IsString()
  @IsOptional()
  excerpt?: string;

  @IsArray()
  @IsOptional()
  tags?: string[];
}
// libs/shared/dto/src/index.ts
export * from './lib/create-post.dto';
export * from './lib/update-post.dto';
export * from './lib/create-user.dto';
export * from './lib/login.dto';

Now both the NestJS backend and Next.js frontend can use the same DTO:

// In NestJS controller — full class-validator support
import { CreatePostDto } from '@my-enterprise/shared-dto';

@Post()
create(@Body() dto: CreatePostDto) { ... }
// In Next.js form — same type, no duplication
import { CreatePostDto } from '@my-enterprise/shared-dto';

function PostForm() {
  const [data, setData] = useState<Partial<CreatePostDto>>({});
}

Shared Types Library

nx generate @nx/js:library shared-types --directory=libs/shared/types --importPath=@my-enterprise/shared-types
// libs/shared/types/src/lib/models.ts
export interface User {
  id: number;
  email: string;
  name: string;
  role: 'admin' | 'editor' | 'reader';
  createdAt: string;
}

export interface Post {
  id: number;
  title: string;
  slug: string;
  excerpt: string;
  content: string;
  coverImage: string;
  category: string;
  tags: string[];
  author: Pick<User, 'id' | 'name'>;
  publishedAt: string;
}

export interface PaginatedResponse<T> {
  data: T[];
  pagination: {
    page: number;
    limit: number;
    total: number;
    totalPages: number;
    hasNext: boolean;
    hasPrev: boolean;
  };
}

Shared Utilities Library

nx generate @nx/js:library shared-utils --directory=libs/shared/utils --importPath=@my-enterprise/shared-utils
// libs/shared/utils/src/lib/slugify.ts
export function slugify(text: string): string {
  return text
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/(^-|-$)/g, '');
}

// libs/shared/utils/src/lib/format-date.ts
export function formatDate(date: string | Date): string {
  return new Intl.DateTimeFormat('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  }).format(new Date(date));
}

// libs/shared/utils/src/lib/calculate-reading-time.ts
export function calculateReadingTime(content: string): number {
  const wordsPerMinute = 200;
  const wordCount = content.split(/\s+/).length;
  return Math.ceil(wordCount / wordsPerMinute);
}

NestJS Module Architecture

NestJS naturally promotes a modular architecture. Each feature should be its own module:

// apps/api/src/app/posts/posts.module.ts
import { Module } from '@nestjs/common';
import { PostsController } from './posts.controller';
import { PostsService } from './posts.service';
import { PrismaModule } from '../../prisma/prisma.module';
import { AuthModule } from '../auth/auth.module';

@Module({
  imports: [
    PrismaModule,  // Database access
    AuthModule,    // JWT verification
  ],
  controllers: [PostsController],
  providers: [PostsService],
  exports: [PostsService], // Export for use in other modules
})
export class PostsModule {}
// apps/api/src/app/posts/posts.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreatePostDto, UpdatePostDto } from '@my-enterprise/shared-dto';
import { slugify, calculateReadingTime } from '@my-enterprise/shared-utils';

@Injectable()
export class PostsService {
  constructor(private prisma: PrismaService) {}

  async create(dto: CreatePostDto, authorId: number) {
    const slug = slugify(dto.title);
    const readingTime = calculateReadingTime(dto.content);

    return this.prisma.post.create({
      data: {
        ...dto,
        slug,
        readingTime,
        authorId,
      },
      include: {
        author: {
          select: { id: true, name: true },
        },
      },
    });
  }

  async findAll(page = 1, limit = 10, category?: string) {
    const skip = (page - 1) * limit;

    const [posts, total] = await Promise.all([
      this.prisma.post.findMany({
        skip,
        take: limit,
        where: {
          published: true,
          ...(category && { category }),
        },
        orderBy: { publishedAt: 'desc' },
        include: {
          author: { select: { id: true, name: true } },
        },
      }),
      this.prisma.post.count({
        where: { published: true, ...(category && { category }) },
      }),
    ]);

    return {
      data: posts,
      pagination: {
        page,
        limit,
        total,
        totalPages: Math.ceil(total / limit),
        hasNext: page * limit < total,
        hasPrev: page > 1,
      },
    };
  }

  async findOne(id: number) {
    const post = await this.prisma.post.findUnique({
      where: { id },
      include: { author: { select: { id: true, name: true } } },
    });

    if (!post) throw new NotFoundException(`Post #${id} not found`);
    return post;
  }

  async update(id: number, dto: UpdatePostDto) {
    await this.findOne(id); // Throws 404 if not found
    return this.prisma.post.update({ where: { id }, data: dto });
  }

  async remove(id: number) {
    await this.findOne(id);
    return this.prisma.post.delete({ where: { id } });
  }
}

The Nx Project Dependency Graph

One of Nx’s most powerful features is the visual project graph. Run:

nx graph

This opens a browser with an interactive graph showing how all your apps and libraries depend on each other. It’s invaluable for understanding your architecture and spotting circular dependencies.


Smart Rebuilding with Nx Affected

This is the feature that makes CI pipelines 10x faster. Instead of running all tests on every push, Nx analyzes which projects are affected by your changes and only runs tasks for those.

# Only lint projects affected by changes on this branch
nx affected:lint --base=origin/main

# Only test affected projects
nx affected:test --base=origin/main --parallel=3

# Only build affected projects
nx affected:build --base=origin/main

# See which projects would be affected before running
nx affected:graph --base=origin/main

Example: If you modify libs/shared/dto, Nx knows that both apps/api and apps/web depend on it — so both get rebuilt and tested. But libs/shared/utils (which doesn’t depend on dto) is skipped entirely.


Nx Caching

Nx caches task outputs. If you run a build and then run it again without changing anything, it completes in milliseconds:

nx build api
# Output: ...builds in 45 seconds...

nx build api  # Run again, nothing changed
# Output: [nx cache] Using cache... Done in 180ms

Configure caching in nx.json:

{
  "tasksRunnerOptions": {
    "default": {
      "runner": "nx/tasks-runners/default",
      "options": {
        "cacheableOperations": ["build", "test", "lint", "type-check"]
      }
    }
  }
}

For teams, connect to Nx Cloud for remote caching — one developer’s build is cached for the entire team:

npx nx connect-to-nx-cloud

GitHub Actions CI with Nx Affected

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  ci:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Full history — Nx needs this to detect affected projects

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - name: Set Nx SHAs
        uses: nrwl/nx-set-shas@v4

      - name: Run affected lint
        run: npx nx affected:lint --base=${{ env.NX_BASE }} --head=${{ env.NX_HEAD }}

      - name: Run affected tests
        run: npx nx affected:test --base=${{ env.NX_BASE }} --head=${{ env.NX_HEAD }} --parallel=3

      - name: Run affected build
        run: npx nx affected:build --base=${{ env.NX_BASE }} --head=${{ env.NX_HEAD }}

Conclusion

The combination of NestJS and Nx transforms backend development from a scaling challenge into a manageable, even enjoyable process. Shared libraries eliminate duplication, smart rebuilding keeps CI fast, and consistent tooling means every developer on your team operates in the same reliable environment.

As your codebase grows from one app to five, from one developer to fifty, the investment in this architecture pays for itself over and over again. The structure is there from day one — you grow into it rather than having to refactor everything later.

Enjoyed this article?

Subscribe to get more insights like this delivered to your inbox.