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:
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:
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:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@app/*": ["apps/*/src"],
"@lib/common/*": ["libs/common/src/*"],
"@lib/database/*": ["libs/database/src/*"]
}
}
}
Now any app can import shared code cleanly:
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:
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.
// .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:
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:
// 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:
// 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:
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:
// 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:
// 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:
- Keep shared libs small and stable.
@lib/common/dtosand@lib/common/authare fine. A 2,000-line utility lib that everyone depends on is not. - Domain-specific libs are better. Instead of
@lib/common/user-service, create@lib/user/typeswith only the types. Let the user service own its logic. - Avoid circular dependencies. If
@lib/ordersimports from@lib/users, and@lib/usersimports from@lib/orders, you have a problem. Nx will warn you, but it's a design smell. - 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.






Comments
Loading comments…