Skip to content
ExponaBLOG
NESTJS · DAY 068 / 100

Monorepos with NestJS: a practical guide for developers

Share code between services and apps without copy-paste.

Expona Admin7 min read

Monorepos with NestJS let you build multiple services and applications in a single repository without the friction of copy-paste or duplicate code. If you're scaling from a monolith to microservices, or managing several related apps, a well-structured monorepo can save months of synchronisation pain. In this post, I'll walk you through the practical trade-offs and tooling that make this work.

Nest CLI monorepo mode — Multiple apps and libs in one workspace with shared tsconfig paths

NestJS has built-in support for monorepo workspaces through the CLI. When you initialise a monorepo, the structure looks like this:

typescript
my-monorepo/
  apps/
    api-users/
      src/
        main.ts
      tsconfig.app.json
    api-orders/
      src/
        main.ts
      tsconfig.app.json
    mobile-app/
      src/
        main.ts
  libs/
    common/
      auth/
      decorators/
      tsconfig.lib.json
    database/
      prisma/
      tsconfig.lib.json
  tsconfig.json
  package.json
  nest-cli.json

To set this up, run:

typescript
nest new my-monorepo --skip-git
cd my-monorepo
nest generate lib common
nest generate app api-orders

The key here is tsconfig path mapping. Your root tsconfig.json defines aliases that resolve across the entire workspace:

typescript
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@app/*": ["apps/*/src"],
      "@lib/common/*": ["libs/common/src/*"],
      "@lib/database/*": ["libs/database/src/*"]
    }
  }
}

Now any app can import shared code cleanly:

typescript
import { JwtAuthGuard } from '@lib/common/auth';
import { PrismaService } from '@lib/database/prisma';

The NestJS CLI handles building each app and lib separately, with nest build api-users compiling only that app and its dependencies. This is lightweight and works well for small to medium teams (3–10 services).

Nx for larger setups — Affected builds, caching and graph-aware CI

As you add more services, maintaining dependency graphs and rebuild strategies becomes tedious. Nx is a build orchestration tool built exactly for this. It automates which services to rebuild and test based on what changed.

Install Nx into your NestJS monorepo:

typescript
npm install -D nx
npx nx init

Nx then reads your workspace and builds a dependency graph. When you commit a change to a shared lib, Nx knows which apps are affected and only rebuilds those. In a CI pipeline with 20+ services, this cuts build time from 15 minutes to 2 minutes.

Example: you modify libs/common/auth/jwt.strategy.ts. Nx determines that only api-users and api-orders import this lib, so it rebuilds and tests only those two apps.

typescript
// .nxignore (tells Nx which files don't affect builds)
dist
node_modules
.env
*.log

// nx.json
{
  "affected": {
    "defaultBase": "main"
  },
  "tasksRunnerOptions": {
    "default": {
      "runner": "@nrwl/nx-cloud",
      "options": {
        "cacheableOperations": ["build", "test", "lint"]
      }
    }
  }
}

In CI, run:

typescript
npx nx affected:build --base=origin/main --head=HEAD
npx nx affected:test --base=origin/main --head=HEAD

Nx also caches build outputs. If a colleague rebuilds a lib that you already built locally, they get your cached output instantly. This is a massive productivity gain on large teams.

Shared libs — DTOs, auth guards, logging and Prisma clients as libraries

The whole point of a monorepo is sharing code. Here's what I typically extract into shared libs:

DTOs and request/response types

Create a @lib/common/dtos folder:

typescript
// libs/common/src/dtos/user.dto.ts
export class CreateUserDto {
  email: string;
  name: string;
  password: string;
}

export class UserResponseDto {
  id: string;
  email: string;
  name: string;
  createdAt: Date;
}

Both api-users and any other service that calls the user API can import these without redefining them. This prevents the "are these fields consistent?" debugging nightmare.

Auth guards and decorators

Move your JWT strategy and role-based guards into a shared lib:

typescript
// libs/common/src/auth/jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@passport-js/passport';
import { Strategy } from 'passport-jwt';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: (req) => req.cookies?.accessToken,
      secretOrKey: process.env.JWT_SECRET,
    });
  }

  validate(payload: any) {
    return { userId: payload.sub, role: payload.role };
  }
}

// libs/common/src/auth/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.get('roles', context.getHandler());
    if (!requiredRoles) return true;

    const request = context.switchToHttp().getRequest();
    const userRole = request.user?.role;

    if (!requiredRoles.includes(userRole)) {
      throw new ForbiddenException('Insufficient permissions');
    }

    return true;
  }
}

Any app imports and uses these consistently:

typescript
import { JwtStrategy } from '@lib/common/auth';
import { RolesGuard } from '@lib/common/auth';

@UseGuards(JwtAuthGuard, RolesGuard)
@SetMetadata('roles', ['admin'])
@Get('sensitive-data')
getSensitiveData() {
  return 'secret';
}

Logging and configuration

Centralise your logging setup:

typescript
// libs/common/src/logging/logger.service.ts
import { Injectable, LoggerService as NestLoggerService } from '@nestjs/common';

@Injectable()
export class Logger implements NestLoggerService {
  log(message: string, context?: string) {
    console.log(`[${context}] ${message}`);
  }

  error(message: string, trace?: string, context?: string) {
    console.error(`[${context}] ERROR: ${message}`, trace);
  }

  warn(message: string, context?: string) {
    console.warn(`[${context}] WARN: ${message}`);
  }
}

Every app gets consistent logging without repeating the configuration.

Database clients

If multiple services share a Prisma schema, create a shared Prisma lib:

typescript
// libs/database/src/prisma/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  async onModuleInit() {
    await this.$connect();
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}

// apps/api-users/src/app.module.ts
import { PrismaService } from '@lib/database/prisma';

@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class DatabaseModule {}

Watch coupling — A shared lib that every service depends on becomes a distributed monolith

Here's the trap I've fallen into, and you should avoid it: creating a @lib/common that every service depends on. This defeats the entire purpose of microservices.

When every app imports from one central lib, you've created a distributed monolith. A single change to that lib risks breaking five services. Deployments become tightly coupled. You can't scale teams independently.

Instead, follow these rules:

  1. Keep shared libs small and stable. @lib/common/dtos and @lib/common/auth are fine. A 2,000-line utility lib that everyone depends on is not.
  2. Domain-specific libs are better. Instead of @lib/common/user-service, create @lib/user/types with only the types. Let the user service own its logic.
  3. Avoid circular dependencies. If @lib/orders imports from @lib/users, and @lib/users imports from @lib/orders, you have a problem. Nx will warn you, but it's a design smell.
  4. Version and publish critical libs separately. If a lib is used by external teams or services, publish it to npm and version it. This forces you to think about API stability.

A real example: I once had a monorepo where every app depended on a "utils" lib. That lib grew to 3,000 lines. Changing a date format function broke three services. We ended up duplicating the lib across services anyway. The lesson: create libs for cohesion, not convenience.

Common mistakes

  • Creating one mega lib instead of many focused ones. Split by domain or concern. A lib should have a clear, narrow purpose.
  • Not setting up path aliases correctly. Without proper tsconfig paths, your imports are messy (../../../libs/common). Invest time in alias setup upfront.
  • Forgetting to declare shared libs in workspace.json or project.json. Nx and the NestJS CLI won't recognise a lib unless it's registered. This causes build errors that are hard to debug.
  • Treating a monorepo as a reason to skip API boundaries. Even in a monorepo, services should communicate via REST or gRPC, not by importing each other's internals. A shared lib should be for DTOs and utilities, not domain logic.
  • Skipping CI optimisation early. If you have 10+ services, set up Nx affected builds from day one. Retrofitting it later is painful.

Key takeaways

  • NestJS monorepo mode handles small setups cleanly; Nx scales to large workspaces with caching and affected builds.
  • Extract DTOs, auth guards, logging, and database clients into small, focused shared libs.
  • Keep shared libs minimal and stable. A lib that every service depends on becomes a distributed monolith and defeats the purpose of microservices.
  • Use Nx to automate which services rebuild and test based on what changed. This cuts CI time dramatically.
  • Enforce API boundaries even within a monorepo. Shared code is for types and utilities, not domain logic.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles