Introduction
NestJS is today the most widely used Node.js backend framework for building scalable, strongly-typed, maintainable server-side applications. It combines the most solid software engineering concepts — Dependency Injection, modules, layered architecture — with the productivity of TypeScript and an ecosystem that covers virtually every need: REST, GraphQL, WebSocket, microservices, CLI, cron jobs.
In this guide we build, step by step, a complete, production-ready REST API: a Blog API with JWT authentication, refresh tokens, Role Based Access Control (RBAC), validation, file uploads, centralized logging, and structured error handling — exactly the stack behind most real-world NestJS APIs in 2026.
What Is NestJS
NestJS is an opinionated framework built on top of Express (or, alternatively, Fastify) that enforces a precise application structure: every feature is organized into modules, each module exposes controllers (which handle HTTP requests) and providers (which hold business logic, typically services), wired together through a built-in Dependency Injection system inspired by Angular.
Why Use It
- Native TypeScript: end-to-end typing, autocompletion, safe refactoring.
- Enforced architecture: unlike plain Express, NestJS forces separation of concerns (controller/service/repository), reducing the "big ball of mud" typical of Node projects that grew without structure.
- Built-in Dependency Injection: high testability, low coupling, interchangeable providers (useful for mocking in tests).
- Mature ecosystem: official modules for TypeORM, Prisma, Mongoose, Passport, GraphQL, WebSocket, Bull/BullMQ, Swagger, gRPC, microservices.
- Declarative decorators: guards, interceptors, pipes, and exception filters allow cross-cutting concerns (auth, logging, validation) without polluting business logic.
When It's Worth It (and When It Isn't)
NestJS pays off when the project has enough complexity to justify a rigid structure: teams with multiple developers, APIs meant to grow over time, high testability requirements, enterprise applications with security and compliance needs. It's probably overkill for a one-off script or a prototype that will be thrown away in a week — there, plain Express or "bare" Fastify remain faster to spin up.
Advantages and Disadvantages
| Advantages | Disadvantages |
|---|---|
| Clear, consistent structure across different projects | Steeper learning curve than plain Express (decorators, DI, modules) |
| Native testability thanks to DI | Boilerplate overhead for very small projects |
| Wide, well-maintained official ecosystem | More "magic" (decorators, reflection) compared to explicit code |
| Excellent integration with TypeScript and Swagger/OpenAPI | Slightly higher bundle size and cold start than minimal frameworks (relevant in serverless environments) |
| Makes adopting Clean/Hexagonal Architecture easier | Requires team discipline to avoid abusing module flexibility |
Real-World Cases
NestJS is used in production by companies such as Adidas, Roche, Autodesk, and Decathlon, and is a very common choice for B2B SaaS backends, e-commerce platforms, user management systems, and microservice APIs that need to communicate with each other via gRPC or message queues (RabbitMQ, Kafka).
Prerequisites
Before starting, make sure you have these tools installed and know the basics of TypeScript (interfaces, decorators, generics) and REST concepts (HTTP verbs, status codes, idempotency).
| Tool | Recommended Version | Purpose |
|---|---|---|
| Node.js | 20.x LTS or higher | JavaScript runtime NestJS runs on |
| npm | 10.x (bundled with Node 20) | Package management |
| NestJS CLI | @nestjs/cli 10.x+ | Scaffolding modules, controllers, services |
| TypeScript | 5.x | Language NestJS and your app are written in |
| VS Code | Latest stable | Editor with native TypeScript support |
| PostgreSQL | 15.x or higher | Relational database for the TypeORM example |
| Docker (optional) | 24.x+ | Run PostgreSQL locally without a native install |
# Verify installed versions
node -v
npm -v
# Install the NestJS CLI globally
npm install -g @nestjs/cli
nest --version
Tip: if you don't want to install PostgreSQL natively, spin it up with Docker:
docker run --name pg-blog -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=blog_db -p 5432:5432 -d postgres:16.
Architecture
Before writing code, it's essential to understand how NestJS routes an HTTP request through its main building blocks. Here's the complete lifecycle of a request:
Client
│
▼
┌─────────────┐
│ Middleware │ (e.g. logger, cookie-parser — runs before routing)
└──────┬───────┘
▼
┌─────────────┐
│ Guard │ (e.g. AuthGuard — decides whether the request can proceed)
└──────┬───────┘
▼
┌─────────────┐
│ Interceptor │ ("before" phase — e.g. logging, input transformation)
└──────┬───────┘
▼
┌─────────────┐
│ Pipe │ (validates and transforms incoming parameters)
└──────┬───────┘
▼
┌─────────────┐
│ Controller │ (receives the request, delegates to the service)
└──────┬───────┘
▼
┌─────────────┐
│ Service │ (business logic, calls the repositories)
└──────┬───────┘
▼
┌─────────────┐
│ Repository / │ (data access — TypeORM, Prisma, Mongoose...)
│ Database │
└──────┬───────┘
▼
┌─────────────┐
│ Interceptor │ ("after" phase — e.g. response transformation)
└──────┬───────┘
▼
┌──────────────────┐
│ Exception Filter │ (intercepts ONLY if an exception is thrown)
└──────┬────────────┘
▼
Response
Controller
The outermost layer: receives HTTP requests, extracts parameters/body/query, and immediately delegates logic to the corresponding service. A controller should never contain business logic — its only responsibility is HTTP-to-method-call mapping.
Service
Holds the actual business logic. It's an injectable provider, typically
marked with @Injectable(), and gets injected into the controller (or other
services) via the constructor.
Module
The module is NestJS's organizational unit: it groups controllers, providers, and imports of
other modules. Every application has at least a root AppModule, and features are
typically isolated into feature modules (e.g. PostsModule,
AuthModule, UsersModule).
Provider
Any class managed by Nest's Dependency Injection container: services, repositories,
factories, helpers. It's declared in a module's providers array and can be
injected wherever it's available (within the same module, or exported to other modules).
Middleware
Functions executed before Nest's routing, with direct access to
req, res, and next() — the same model as Express.
Useful for raw logging, cookie parsing, or custom headers applied globally.
Guard
Decide whether a request can proceed, returning true/
false (or throwing an exception). They are the correct place for authentication
and authorization — never inside the controller or service.
Interceptor
Wrap around the execution of the route handler (like AOP middleware): they can transform the request before it reaches the controller and the response before it goes out. Typical use cases: logging response times, uniform response transformation, caching, timeout handling.
Pipe
Transform and validate incoming data (route parameters, query strings, body) before it
reaches the controller. ValidationPipe, integrated with
class-validator, is by far the most widely used pipe in any serious NestJS API.
Exception Filter
Intercept exceptions thrown anywhere in the request lifecycle and turn them into a consistent HTTP response (status code, structured JSON body), preventing raw stack traces or errors from reaching the client.
Installation
# 1. Create a new NestJS project
nest new blog-api
# During creation, choose npm as the package manager when prompted
cd blog-api
# 2. Install the database dependencies (TypeORM + PostgreSQL driver)
npm install @nestjs/typeorm typeorm pg
# 3. Install the validation dependencies
npm install class-validator class-transformer
# 4. Install the JWT authentication dependencies
npm install @nestjs/jwt @nestjs/passport passport passport-jwt bcrypt
npm install --save-dev @types/passport-jwt @types/bcrypt
# 5. Install the security and rate-limiting dependencies
npm install helmet @nestjs/throttler
# 6. Install the file upload dependencies
npm install @nestjs/platform-express multer
npm install --save-dev @types/multer
# 7. Install Swagger for automatic API documentation
npm install @nestjs/swagger
# 8. Install the structured logger
npm install nestjs-pino pino-http pino-pretty
# 9. Set up environment variables
npm install @nestjs/config
Each command installs a precise functional block: @nestjs/typeorm +
typeorm + pg connect Nest to PostgreSQL through the TypeORM ORM;
class-validator/class-transformer enable automatically validated
DTOs; the passport/passport-jwt/bcrypt stack builds the
entire authentication flow; helmet and @nestjs/throttler harden HTTP
security and rate limiting; multer handles multipart/form-data for uploads;
nestjs-pino provides structured JSON logging, suitable for production.
Step-by-Step Implementation
1. Folder Structure
src/
├── main.ts
├── app.module.ts
├── config/
│ └── configuration.ts
├── common/
│ ├── filters/
│ │ └── http-exception.filter.ts
│ ├── interceptors/
│ │ ├── logging.interceptor.ts
│ │ └── transform.interceptor.ts
│ ├── guards/
│ │ └── roles.guard.ts
│ ├── decorators/
│ │ └── roles.decorator.ts
│ └── pipes/
│ └── parse-object-id.pipe.ts
├── auth/
│ ├── auth.module.ts
│ ├── auth.controller.ts
│ ├── auth.service.ts
│ ├── strategies/
│ │ ├── jwt.strategy.ts
│ │ └── jwt-refresh.strategy.ts
│ └── dto/
│ ├── login.dto.ts
│ └── register.dto.ts
├── users/
│ ├── users.module.ts
│ ├── users.service.ts
│ └── entities/
│ └── user.entity.ts
└── posts/
├── posts.module.ts
├── posts.controller.ts
├── posts.service.ts
├── entities/
│ └── post.entity.ts
└── dto/
├── create-post.dto.ts
└── update-post.dto.ts
2. Centralized Configuration with @nestjs/config
// src/config/configuration.ts
export default () => ({
port: parseInt(process.env.PORT ?? '3000', 10),
database: {
host: process.env.DB_HOST ?? 'localhost',
port: parseInt(process.env.DB_PORT ?? '5432', 10),
username: process.env.DB_USERNAME ?? 'postgres',
password: process.env.DB_PASSWORD ?? 'postgres',
name: process.env.DB_NAME ?? 'blog_db',
},
jwt: {
accessSecret: process.env.JWT_ACCESS_SECRET ?? 'change-me-access',
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN ?? '15m',
refreshSecret: process.env.JWT_REFRESH_SECRET ?? 'change-me-refresh',
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN ?? '7d',
},
});
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule } from '@nestjs/throttler';
import configuration from './config/configuration';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { PostsModule } from './posts/posts.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, load: [configuration] }),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (cfg: ConfigService) => ({
type: 'postgres',
host: cfg.get('database.host'),
port: cfg.get('database.port'),
username: cfg.get('database.username'),
password: cfg.get('database.password'),
database: cfg.get('database.name'),
autoLoadEntities: true,
synchronize: process.env.NODE_ENV !== 'production', // ⚠️ development only
}),
}),
ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]),
AuthModule,
UsersModule,
PostsModule,
],
})
export class AppModule {}
Line by line: ConfigModule.forRoot({ isGlobal: true }) makes
ConfigService available throughout the whole application without having to
re-import the module everywhere. TypeOrmModule.forRootAsync builds the database
connection asynchronously, reading values from ConfigService instead of a static
object — necessary in order to use environment variables. synchronize: true
makes TypeORM automatically create/update tables based on the entities: very convenient in
development, dangerous in production (it can drop data), where migrations
will be used instead.
3. Entities with TypeORM
// src/users/entities/user.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, OneToMany } from 'typeorm';
import { Post } from '../../posts/entities/post.entity';
import { Exclude } from 'class-transformer';
export enum UserRole {
ADMIN = 'admin',
AUTHOR = 'author',
READER = 'reader',
}
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
email: string;
@Column()
@Exclude() // excludes the password hash from serialized responses
passwordHash: string;
@Column({ type: 'enum', enum: UserRole, default: UserRole.READER })
role: UserRole;
@OneToMany(() => Post, (post) => post.author)
posts: Post[];
@CreateDateColumn()
createdAt: Date;
}
// src/posts/entities/post.entity.ts
import {
Entity, Column, PrimaryGeneratedColumn, ManyToOne,
CreateDateColumn, UpdateDateColumn, Index,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
@Entity('posts')
export class Post {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
title: string;
@Index({ unique: true })
@Column()
slug: string;
@Column('text')
content: string;
@Column({ default: false })
published: boolean;
@Column({ nullable: true })
coverImageUrl?: string;
@ManyToOne(() => User, (user) => user.posts, { eager: true })
author: User;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
4. DTOs with class-validator
// src/posts/dto/create-post.dto.ts
import { IsString, IsBoolean, IsOptional, MinLength, MaxLength } from 'class-validator';
export class CreatePostDto {
@IsString()
@MinLength(5)
@MaxLength(200)
title: string;
@IsString()
@MinLength(20)
content: string;
@IsBoolean()
@IsOptional()
published?: boolean;
}
// src/posts/dto/update-post.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreatePostDto } from './create-post.dto';
export class UpdatePostDto extends PartialType(CreatePostDto) {}
PartialType automatically generates a version where every field of the original
DTO becomes optional — perfect for partial updates (PATCH), without duplicating
validation decorators.
5. Enabling ValidationPipe Globally
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import helmet from 'helmet';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(helmet());
app.enableCors({ origin: process.env.CORS_ORIGIN?.split(',') ?? '*' });
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // strips properties not present in the DTO
forbidNonWhitelisted: true, // throws an error if extra properties arrive
transform: true, // automatically converts types (e.g. string → number)
}),
);
app.useGlobalFilters(new HttpExceptionFilter());
app.setGlobalPrefix('api/v1');
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
whitelist: true + forbidNonWhitelisted: true are the most important
pair for DTO security: without it, a client could send extra fields (e.g.
role: 'admin' in a registration request) that TypeORM might inadvertently
persist if the code doesn't filter them out explicitly elsewhere.
6. Custom Middleware
// src/common/middleware/request-id.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'crypto';
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction): void {
req['requestId'] = randomUUID();
res.setHeader('X-Request-Id', req['requestId']);
next();
}
}
// registration in app.module.ts
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestIdMiddleware).forRoutes('*');
}
}
7. Guards: JwtAuthGuard and RolesGuard
// src/auth/strategies/jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get('jwt.accessSecret'),
});
}
async validate(payload: { sub: string; email: string; role: string }) {
// The returned value is attached to req.user
return { userId: payload.sub, email: payload.email, role: payload.role };
}
}
// src/common/decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '../../users/entities/user.entity';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
// src/common/guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { UserRole } from '../../users/entities/user.entity';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) return true; // no role restriction on this route
const { user } = context.switchToHttp().getRequest();
return requiredRoles.includes(user?.role);
}
}
The Reflector.getAllAndOverride pattern reads metadata set by the
@Roles(...) decorator at both the single-method and whole-class level, letting
you define a role restriction on an entire controller and override it on individual routes
when needed.
8. Interceptors: Logging and Response Transformation
// src/common/interceptors/logging.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(context: ExecutionContext, next: CallHandler): Observable {
const req = context.switchToHttp().getRequest();
const start = Date.now();
return next.handle().pipe(
tap(() => {
const ms = Date.now() - start;
this.logger.log(`${req.method} ${req.url} — ${ms}ms`);
}),
);
}
}
// src/common/interceptors/transform.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
export interface Response {
success: true;
data: T;
timestamp: string;
}
@Injectable()
export class TransformInterceptor implements NestInterceptor> {
intercept(context: ExecutionContext, next: CallHandler): Observable> {
return next.handle().pipe(
map((data) => ({
success: true,
data,
timestamp: new Date().toISOString(),
})),
);
}
}
9. Global Exception Filter
// src/common/filters/http-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { Request, Response } from 'express';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger('ExceptionFilter');
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const status = exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message = exception instanceof HttpException
? exception.getResponse()
: 'Errore interno del server';
// Logs the full stack trace only server-side, never in the response to the client
if (!(exception instanceof HttpException)) {
this.logger.error(exception instanceof Error ? exception.stack : exception);
}
response.status(status).json({
success: false,
statusCode: status,
path: request.url,
timestamp: new Date().toISOString(),
message,
});
}
}
A crucial security detail: when the exception is not a known
HttpException (i.e. an unexpected error, e.g. a bug or a database error), the
message returned to the client is generic ("Internal server error") — the real stack trace is
only logged server-side. Exposing stack traces to clients is a well-known security issue
(information disclosure).
10. Custom Pipe
// src/common/pipes/parse-uuid-or-404.pipe.ts
import { PipeTransform, Injectable, ArgumentMetadata, NotFoundException } from '@nestjs/common';
import { isUUID } from 'class-validator';
@Injectable()
export class ParseUuidOr404Pipe implements PipeTransform {
transform(value: string, _metadata: ArgumentMetadata): string {
if (!isUUID(value)) {
throw new NotFoundException(`Risorsa con id "${value}" non trovata`);
}
return value;
}
}
Returning a 404 instead of a generic 400 Bad Request when the id
isn't even a valid UUID is a deliberate choice: from the client's perspective, "resource not
found" is semantically more correct and doesn't reveal details about the internal id format.
Real-World Example: A Complete Blog API
Let's put all the pieces together into a complete PostsModule, with CRUD,
authentication, role-based authorization, and cover image upload.
Auth Service: Login, Register, Refresh Token
// src/auth/auth.service.ts
import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import { UsersService } from '../users/users.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService,
private config: ConfigService,
) {}
async register(dto: RegisterDto) {
const existing = await this.usersService.findByEmail(dto.email);
if (existing) throw new ConflictException('Email già registrata');
const passwordHash = await bcrypt.hash(dto.password, 12);
const user = await this.usersService.create({ ...dto, passwordHash });
return this.buildTokens(user.id, user.email, user.role);
}
async login(dto: LoginDto) {
const user = await this.usersService.findByEmail(dto.email);
if (!user) throw new UnauthorizedException('Credenziali non valide');
const passwordValid = await bcrypt.compare(dto.password, user.passwordHash);
if (!passwordValid) throw new UnauthorizedException('Credenziali non valide');
return this.buildTokens(user.id, user.email, user.role);
}
async refresh(userId: string, email: string, role: string) {
// In production: also verify that the refresh token is still valid server-side
// (whitelist/blacklist) so it can be revoked, e.g. at logout.
return this.buildTokens(userId, email, role);
}
private buildTokens(sub: string, email: string, role: string) {
const payload = { sub, email, role };
const accessToken = this.jwtService.sign(payload, {
secret: this.config.get('jwt.accessSecret'),
expiresIn: this.config.get('jwt.accessExpiresIn'),
});
const refreshToken = this.jwtService.sign(payload, {
secret: this.config.get('jwt.refreshSecret'),
expiresIn: this.config.get('jwt.refreshExpiresIn'),
});
return { accessToken, refreshToken };
}
}
Why two separate tokens? The access token has a short lifespan (15 minutes) and is the one sent with every request — if stolen, the damage is time-limited. The refresh token has a longer lifespan (days) but is only used to obtain a new access token on a dedicated endpoint, reducing the attack surface.
Auth Controller
// src/auth/auth.controller.ts
import { Controller, Post, Body, UseGuards, Req } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('register')
register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@Post('login')
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@UseGuards(AuthGuard('jwt-refresh'))
@Post('refresh')
refresh(@Req() req) {
return this.authService.refresh(req.user.userId, req.user.email, req.user.role);
}
}
Posts Controller: CRUD, RBAC, and Upload
// src/posts/posts.controller.ts
import {
Controller, Get, Post, Body, Patch, Param, Delete,
UseGuards, UseInterceptors, UploadedFile, Query,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { AuthGuard } from '@nestjs/passport';
import { PostsService } from './posts.service';
import { CreatePostDto } from './dto/create-post.dto';
import { UpdatePostDto } from './dto/update-post.dto';
import { Roles } from '../common/decorators/roles.decorator';
import { RolesGuard } from '../common/guards/roles.guard';
import { UserRole } from '../users/entities/user.entity';
import { ParseUuidOr404Pipe } from '../common/pipes/parse-uuid-or-404.pipe';
import { CurrentUser } from '../common/decorators/current-user.decorator';
@Controller('posts')
export class PostsController {
constructor(private postsService: PostsService) {}
@Get()
findAll(@Query('page') page = 1, @Query('limit') limit = 10) {
return this.postsService.findAll(Number(page), Number(limit));
}
@Get(':id')
findOne(@Param('id', ParseUuidOr404Pipe) id: string) {
return this.postsService.findOne(id);
}
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles(UserRole.AUTHOR, UserRole.ADMIN)
@Post()
create(@Body() dto: CreatePostDto, @CurrentUser() user) {
return this.postsService.create(dto, user.userId);
}
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles(UserRole.AUTHOR, UserRole.ADMIN)
@Patch(':id')
update(@Param('id', ParseUuidOr404Pipe) id: string, @Body() dto: UpdatePostDto, @CurrentUser() user) {
return this.postsService.update(id, dto, user.userId, user.role);
}
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles(UserRole.ADMIN)
@Delete(':id')
remove(@Param('id', ParseUuidOr404Pipe) id: string) {
return this.postsService.remove(id);
}
@UseGuards(AuthGuard('jwt'))
@Post(':id/cover')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
fileFilter: (_req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
cb(null, allowed.includes(file.mimetype));
},
}))
uploadCover(@Param('id', ParseUuidOr404Pipe) id: string, @UploadedFile() file: Express.Multer.File) {
return this.postsService.setCoverImage(id, file);
}
}
Notice how every route applies only the strictly necessary guards and roles:
findAll/findOne are public (no guard), create/
update require the author or admin role, while
remove is reserved for admin only — a concrete example of the
principle of least privilege applied route by route.
Posts Service
// src/posts/posts.service.ts
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Post } from './entities/post.entity';
import { CreatePostDto } from './dto/create-post.dto';
import { UpdatePostDto } from './dto/update-post.dto';
import { UserRole } from '../users/entities/user.entity';
import slugify from 'slugify';
@Injectable()
export class PostsService {
constructor(@InjectRepository(Post) private postsRepo: Repository) {}
async findAll(page: number, limit: number) {
const [items, total] = await this.postsRepo.findAndCount({
where: { published: true },
order: { createdAt: 'DESC' },
skip: (page - 1) * limit,
take: limit,
});
return { items, total, page, limit };
}
async findOne(id: string): Promise {
const post = await this.postsRepo.findOne({ where: { id } });
if (!post) throw new NotFoundException(`Post ${id} non trovato`);
return post;
}
async create(dto: CreatePostDto, authorId: string): Promise {
const post = this.postsRepo.create({
...dto,
slug: slugify(dto.title, { lower: true, strict: true }),
author: { id: authorId } as any,
});
return this.postsRepo.save(post);
}
async update(id: string, dto: UpdatePostDto, userId: string, userRole: UserRole): Promise {
const post = await this.findOne(id);
// An author can only edit their own posts; an admin can edit all of them
if (userRole !== UserRole.ADMIN && post.author.id !== userId) {
throw new ForbiddenException('Non puoi modificare un post di un altro autore');
}
Object.assign(post, dto);
return this.postsRepo.save(post);
}
async remove(id: string): Promise {
const result = await this.postsRepo.delete(id);
if (result.affected === 0) throw new NotFoundException(`Post ${id} non trovato`);
}
async setCoverImage(id: string, file: Express.Multer.File): Promise {
const post = await this.findOne(id);
// In production: upload to S3/Cloud Storage instead of local disk
post.coverImageUrl = `/uploads/${file.filename}`;
return this.postsRepo.save(post);
}
}
Notice the check userRole !== UserRole.ADMIN && post.author.id !== userId
in the update method: it's an example of resource-level authorization
(not just route-level) — an author authenticated with a valid JWT can still be blocked if
they're attempting to modify someone else's resource. This check must always be done in the
service layer, never delegated solely to the guard, which operates at a level too generic to
know who owns a specific resource.
Best Practices
| Principle | Practical Application in NestJS |
|---|---|
| Clean Architecture | Clear separation between controller (HTTP), service (business logic), and repository (persistence). The domain should never depend on infrastructure details like Express or TypeORM. |
| SOLID — Single Responsibility | A controller maps HTTP requests, a service holds a single area of business logic, a repository a single aggregate. |
| SOLID — Dependency Inversion | Services depend on abstract interfaces/tokens (e.g. @InjectRepository), not concrete implementations — replaceable with mocks in tests. |
| DRY | Shared validation logic in DTOs via PartialType/PickType/OmitType, avoiding duplication between Create and Update DTOs. |
| KISS | Avoid introducing CQRS, Event Sourcing, or microservices until the real complexity of the domain justifies it. |
| Repository Pattern | TypeORM/Prisma already provide this layer; avoid "poking holes" in the abstraction by calling raw SQL queries directly in services except for performance-critical cases. |
| DTO | Every endpoint has a dedicated input DTO — never expose database entities directly as the API contract. |
| Validation | Global ValidationPipe with whitelist and forbidNonWhitelisted always on in every project, no exceptions. |
Performance
Caching
// Endpoint-level caching with @nestjs/cache-manager
import { CacheInterceptor, CacheTTL } from '@nestjs/cache-manager';
import { UseInterceptors } from '@nestjs/common';
@UseInterceptors(CacheInterceptor)
@CacheTTL(60) // cache for 60 seconds
@Get()
findAll() {
return this.postsService.findAll(1, 10);
}
Module Lazy Loading
In large monolithic applications, LazyModuleLoader lets you load rarely used
modules (e.g. a reporting module) only when actually needed, reducing bootstrap time.
Optimized Queries
- Use explicit
selectin TypeORM to avoid transferring unnecessary columns (e.g. excludingcontentfrom list views). - Add indexes on columns frequently used in
WHERE/ORDER BY(see@Indexon the slug in thePostentity). - Avoid the N+1 problem: use
relationsorQueryBuilderwithleftJoinAndSelectinstead of loading relations inside a loop. - Always use pagination (
skip/take) — never return unbounded collections.
Logging and Monitoring
In production, replace the default logger with nestjs-pino for structured JSON
logs, easily indexable by tools like Grafana Loki or Datadog. For performance monitoring,
integrate @nestjs/terminus for health checks (/health) used by load
balancers and orchestrators (Kubernetes, Railway).
Profiling
To find real bottlenecks, use node --prof or APM (Application Performance
Monitoring) tools such as New Relic or Elastic APM, which track the time spent in every
handler, query, and external call — avoid optimizing "by feel" without real data.
Security
| Measure | How to Implement It |
|---|---|
| JWT | Short-lived access token signed with a strong secret (32+ random characters), never hardcoded in the code. |
| Refresh Token | Longer lifespan, ideally revocable (whitelist in Redis/DB), rotated on every use. |
| Password Hashing / bcrypt | bcrypt.hash(password, 12) — never MD5/SHA1, never plaintext passwords in the DB. |
| Helmet | app.use(helmet()) sets security headers (CSP, X-Frame-Options, HSTS) in a single line. |
| CORS | app.enableCors({ origin: [...] }) with an explicit domain whitelist, never origin: '*' in production if the API requires credentials. |
| Rate Limiting | @nestjs/throttler to limit requests per IP, essential on endpoints like /auth/login to mitigate brute-force attacks. |
| ValidationPipe | whitelist + forbidNonWhitelisted to block mass assignment on unexpected fields. |
| Input Sanitization | class-validator for structure, plus explicit HTML sanitization (e.g. sanitize-html) if a field allows free-form markup, to prevent XSS. |
// Example: more aggressive rate limiting specifically on login
import { Throttle } from '@nestjs/throttler';
@Throttle({ default: { limit: 5, ttl: 60000 } }) // max 5 attempts per minute per IP
@Post('login')
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
Common Errors
1. Forgetting whitelist/forbidNonWhitelisted in ValidationPipe
Problem: a client can send extra fields not expected by the DTO (e.g. role: 'admin').
Cause: ValidationPipe configured without whitelist: true.
Solution: always enable whitelist and forbidNonWhitelisted globally in main.ts.
2. synchronize: true in Production
Problem: TypeORM can alter/drop columns or tables at runtime.
Cause: confusion between development and production environments in the database configuration.
Solution: synchronize: false in production, manage the schema with explicit migrations (typeorm migration:generate).
3. Circular Dependency Between Modules
Problem: "Nest cannot resolve dependencies" error or a silent crash at startup.
Cause: two modules directly import each other.
Solution: use forwardRef(() => ModuleB) in both modules, or — better — extract the shared dependency into a third module.
4. Exposing Entities Directly as the API Response
Problem: sensitive fields (e.g. passwordHash) end up in the JSON response.
Cause: the controller returns the TypeORM entity as-is, without a DTO/serializer layer.
Solution: use class-transformer's @Exclude() on the entity together with a global ClassSerializerInterceptor, or better yet an explicit response DTO.
5. Passwords Stored Without Hashing
Problem: a database breach exposes every password in plaintext.
Cause: the hashing step is omitted (often during rapid prototyping).
Solution: hash with bcrypt/argon2 always, even in development — there's never a valid reason to skip it.
6. Weak or Hardcoded JWT Secrets
Problem: a predictable secret allows forging valid tokens.
Cause: a default secret left in production, or committed to the repository.
Solution: randomly generated secrets (at least 256 bits), managed only through environment variables/a secret manager, never in source code.
7. Missing Async Error Handling in Services
Problem: unhandled promise rejections crash the Node process.
Cause: async calls without try/catch where needed, or forgetting the global exception filter.
Solution: let exceptions bubble up to the global exception filter (Nest's default behavior), intercept only where different behavior is needed (fallback, retry).
8. Undetected N+1 Queries
Problem: the endpoint becomes extremely slow as data grows.
Cause: loading relations inside a loop instead of with a single join.
Solution: use relations/leftJoinAndSelect, monitor generated queries with logging: true in development.
9. CORS Wide Open in Production with Credentials
Problem: any website can make authenticated requests to the API on behalf of the user.
Cause: origin: '*' combined with credentials: true.
Solution: explicit whitelist of authorized domains.
10. No Rate Limiting on Authentication Endpoints
Problem: brute-force attacks on login/registration.
Cause: @nestjs/throttler not applied, or with thresholds that are too permissive.
Solution: a stricter @Throttle specifically on login/register/password-reset.
11. Update DTO Identical to Create DTO Without PartialType
Problem: every PATCH requires all mandatory fields, breaking partial updates.
Cause: the Create DTO is copy-pasted without making fields optional.
Solution: class UpdateXDto extends PartialType(CreateXDto) {}.
12. Guards Applied Only at the Route Level, Never the Resource Level
Problem: an authenticated user can modify other users' resources.
Cause: relying only on @UseGuards(AuthGuard('jwt')) without an ownership check in the service.
Solution: explicit resource.ownerId === user.id check (or admin role) in the service, as shown in the PostsService.update example.
13. Database Connection Without a Properly Configured Pool
Problem: connection exhaustion under load, "too many clients" errors.
Cause: default pool values not suited to real traffic.
Solution: explicitly configure extra: { max: 20 } in the TypeORM connection, calibrated to the database plan.
14. File Uploads Without Size or Type Limits
Problem: a malicious user uploads huge files or executables disguised as images.
Cause: FileInterceptor configured without limits or a fileFilter.
Solution: always set limits.fileSize and validate the mimetype, as in the uploadCover example.
15. Environment Variables Not Validated at Startup
Problem: the app starts "silently" with incomplete configuration and fails cryptically later on.
Cause: no validation schema on env vars.
Solution: use ConfigModule.forRoot({ validationSchema: Joi.object({...}) }) to fail bootstrap immediately if a critical variable is missing.
16. Logging Sensitive Data
Problem: passwords, tokens, or personal data end up in plaintext logs.
Cause: indiscriminate logging of the entire request body.
Solution: explicitly redact sensitive fields before logging, or use pino's redact paths.
17. Tests Written Only for the "Happy Path"
Problem: bugs that only surface on invalid input or edge cases remain invisible until production.
Cause: time pressure, test coverage limited to the main flow.
Solution: explicitly test expected 4xx errors (validation, permissions, not found), not just 2xx.
18. Missing API Versioning
Problem: every breaking change breaks existing clients without warning.
Cause: no version prefix (/api/v1) or Nest versioning strategy configured.
Solution: app.setGlobalPrefix('api/v1') from day one, even on small projects.
19. Outdated Dependencies With Known Vulnerabilities
Problem: the API stays exposed to publicly documented CVEs.
Cause: lack of a periodic dependency audit process.
Solution: npm audit integrated into CI, regular updates via Dependabot/Renovate.
20. No Distinction Between Validation Errors and Domain Errors
Problem: every error comes back as a generic 500, making it impossible for the client to distinguish bad input from a server problem.
Cause: using generic Error instead of Nest's specific HttpException classes.
Solution: always use the correct typed exceptions (BadRequestException, NotFoundException, ForbiddenException, ConflictException...) — that's how Nest communicates the correct HTTP status code.
FAQ
1. Is NestJS suitable for small projects too?
Yes, but the real value shows up as the project grows: on a small script, the structure can feel excessive compared to plain Express.
2. Do I have to use TypeScript?
Technically NestJS also supports plain JavaScript, but you lose most of the value (typed decorators, type-safe DI). It's strongly discouraged in production.
3. TypeORM or Prisma?
TypeORM integrates more natively with NestJS decorators and is more mature within the Nest ecosystem; Prisma offers a more type-safe generated client and a better developer experience for migrations, but requires a manual integration layer with Nest.
4. How do I handle database migrations?
With TypeORM: typeorm migration:generate to generate them from the entity diff, typeorm migration:run to apply them in CI/CD, never synchronize: true in production.
5. How do I structure a very large project?
Modularize by domain (feature modules), not by technical type — avoid global "controllers/", "services/" folders that mix different domains together.
6. How do I test controllers?
With Test.createTestingModule from @nestjs/testing, mocking the injected services; for services, mock the TypeORM repositories with getRepositoryToken.
7. What's the difference between a Guard and Middleware for authentication?
Middleware doesn't have access to Nest's execution context (e.g. decorator metadata), so it can't read @Roles(). Guards can — that's why authentication/authorization always belongs in guards, not middleware.
8. Can I use GraphQL instead of REST?
Yes, NestJS has official support for both Apollo Server and Mercurius, with the same module/DI system — useful if you need flexible client-side queries.
9. How do I handle database transactions?
With TypeORM's DataSource.transaction(), or with the @Transactional() decorator from the typeorm-transactional library for a more declarative syntax.
10. How do I implement refresh tokens securely?
Store a hash of the refresh token server-side (whitelist), rotate it on every use (token rotation), and explicitly invalidate it on logout.
11. Does NestJS support microservices?
Yes, natively, with transport layers for TCP, Redis, RabbitMQ, Kafka, gRPC, and NATS via @nestjs/microservices.
12. How do I deploy to production?
Build with nest build (compiles to dist/), then node dist/main.js; on platforms like Railway/Render/Fly.io, a multi-stage Dockerfile or the native Node buildpack is enough.
13. How do I manage different environment variables for dev/staging/prod?
ConfigModule.forRoot({ envFilePath: ['.env.${NODE_ENV}', '.env'] }), with real values injected by the hosting platform in production, not from committed .env files.
14. How do I implement uploads to S3 instead of local disk?
Replace Multer's storage engine with multer-s3, or handle the upload manually in the service with the AWS SDK after receiving the buffer in memory.
15. How do I auto-document the API?
With @nestjs/swagger: @ApiTags, @ApiProperty decorators on DTOs, and SwaggerModule.setup() in main.ts generate an interactive OpenAPI UI at /api/docs.
16. How do I implement soft delete?
TypeORM natively supports @DeleteDateColumn() on the entity: softDelete() sets the column instead of removing the row, and queries automatically exclude deleted records.
17. How do I manage multiple API versions at the same time?
With app.enableVersioning({ type: VersioningType.URI }) and the @Version('2') decorator on the individual controllers/methods that need to coexist with v1.
18. Do I need an ORM, or can I use raw SQL queries?
For most cases an ORM reduces errors and boilerplate; for very complex or performance-critical queries, TypeORM still allows raw queries via QueryRunner while keeping the rest of the app on the ORM.
19. How do I implement WebSocket in NestJS?
With @nestjs/websockets and a @WebSocketGateway(), which integrates with Socket.IO or ws while keeping the same DI and guard system as REST routes.
20. How do I handle cron jobs?
With @nestjs/schedule and the @Cron('0 0 * * *') decorator on a service method — useful for data cleanup, sending digest emails, periodic syncs.
21. How do I protect the API against SQL Injection attacks?
TypeORM/Prisma automatically parameterize queries — the risk only arises if you build raw queries by manually concatenating strings, which should always be avoided.
22. What's the correct way to manage secrets in production?
Never in code or committed files: use the hosting platform's environment variables or a dedicated secret manager (AWS Secrets Manager, Doppler, Railway Variables).
23. How do I scale a NestJS API horizontally?
The app must be stateless (no local in-memory sessions — use JWT or Redis for shared state), then it's replicated behind a load balancer; for WebSocket, a Redis adapter is needed to sync connections across instances.
24. How do I handle backward compatibility when I change a response schema?
Introduce a new field instead of renaming the existing one, deprecate it gradually with clear documentation, and use API versioning for genuinely breaking changes.
25. Is it worth writing E2E tests in addition to unit tests for a NestJS API?
Yes — @nestjs/testing with supertest lets you test the entire pipeline (including guards, pipes, interceptors) by calling real endpoints on an in-memory app instance, catching integration problems that unit tests alone can't see.
Official Resources and Documentation
To dig deeper into every topic covered in this guide, the official documentation remains the most reliable and up-to-date source:
- NestJS — official documentation:
docs.nestjs.com, in particular the sections on Guards, Interceptors, Pipes, and Exception Filters. - TypeORM — official documentation:
typeorm.io, for relations, migrations, and the advanced query builder. - Passport.js:
passportjs.org, the authentication library@nestjs/passportis built on. - class-validator: official GitHub repository, with the full list of available validation decorators beyond those used in this guide.
- OWASP API Security Top 10: the reference checklist for REST API security, complementary to this guide's Security section.
- jwt.io: a tool for inspecting and decoding JWT tokens while debugging authentication.
Recommended Related Articles
This guide covers the entire lifecycle of a NestJS REST API, but every section can become a deep dive on its own. Here are the naturally connected topics worth exploring next:
- Modularizing a large NestJS project: feature modules, shared modules, and barrel files.
- Dependency Injection in NestJS explained with examples: custom providers, factory providers, injection tokens.
- JWT Authentication and Refresh Tokens in depth: token rotation, revocation, and multi-device management.
- Advanced Role Based Access Control (RBAC): granular permissions beyond simple roles.
- Validation with ValidationPipe and class-validator: custom validators and localized error messages.
- Centralized logging in NestJS with Pino and request correlation via request IDs.
- Custom exception filters for specific domains (e.g. payment errors, domain errors).
- Advanced file uploads with Multer: S3 storage, validating actual file content.
- Scheduler and Cron Jobs in NestJS with
@nestjs/schedule. - WebSocket with NestJS: real-time notifications and scaling with the Redis adapter.
- Testing NestJS controllers and services: unit tests, mocking repositories, E2E tests with Supertest.
Conclusion
Building a REST API with NestJS means investing in a structure that scales with the project's complexity: well-isolated modules, Dependency Injection for testability, guards/interceptors/ pipes to separate cross-cutting concerns from business logic, and an ecosystem — TypeORM, Passport, class-validator, Swagger — that maturely covers every real production need. The Blog API example built in this guide (CRUD, JWT with refresh tokens, RBAC, file upload, centralized logging, error handling) is the reusable skeleton for most NestJS APIs you'll encounter in practice.
Recommended next steps: dig deeper into modularizing large NestJS projects, JWT authentication and refresh tokens in detail, the RBAC pattern with granular permissions, and testing techniques for controllers and services — topics that naturally complete this guide.