<link rel="stylesheet" href="/assets/fonts/jetbrains-mono/jetbrains-mono.css" />
All posts

Building a complete REST API with Nestjs: The definitive Guide to Typeorm, JWT, RBAC, and Security

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

AdvantagesDisadvantages
Clear, consistent structure across different projectsSteeper learning curve than plain Express (decorators, DI, modules)
Native testability thanks to DIBoilerplate overhead for very small projects
Wide, well-maintained official ecosystemMore "magic" (decorators, reflection) compared to explicit code
Excellent integration with TypeScript and Swagger/OpenAPISlightly higher bundle size and cold start than minimal frameworks (relevant in serverless environments)
Makes adopting Clean/Hexagonal Architecture easierRequires 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).

ToolRecommended VersionPurpose
Node.js20.x LTS or higherJavaScript runtime NestJS runs on
npm10.x (bundled with Node 20)Package management
NestJS CLI@nestjs/cli 10.x+Scaffolding modules, controllers, services
TypeScript5.xLanguage NestJS and your app are written in
VS CodeLatest stableEditor with native TypeScript support
PostgreSQL15.x or higherRelational 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

PrinciplePractical Application in NestJS
Clean ArchitectureClear separation between controller (HTTP), service (business logic), and repository (persistence). The domain should never depend on infrastructure details like Express or TypeORM.
SOLID — Single ResponsibilityA controller maps HTTP requests, a service holds a single area of business logic, a repository a single aggregate.
SOLID — Dependency InversionServices depend on abstract interfaces/tokens (e.g. @InjectRepository), not concrete implementations — replaceable with mocks in tests.
DRYShared validation logic in DTOs via PartialType/PickType/OmitType, avoiding duplication between Create and Update DTOs.
KISSAvoid introducing CQRS, Event Sourcing, or microservices until the real complexity of the domain justifies it.
Repository PatternTypeORM/Prisma already provide this layer; avoid "poking holes" in the abstraction by calling raw SQL queries directly in services except for performance-critical cases.
DTOEvery endpoint has a dedicated input DTO — never expose database entities directly as the API contract.
ValidationGlobal 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 select in TypeORM to avoid transferring unnecessary columns (e.g. excluding content from list views).
  • Add indexes on columns frequently used in WHERE/ORDER BY (see @Index on the slug in the Post entity).
  • Avoid the N+1 problem: use relations or QueryBuilder with leftJoinAndSelect instead 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

MeasureHow to Implement It
JWTShort-lived access token signed with a strong secret (32+ random characters), never hardcoded in the code.
Refresh TokenLonger lifespan, ideally revocable (whitelist in Redis/DB), rotated on every use.
Password Hashing / bcryptbcrypt.hash(password, 12) — never MD5/SHA1, never plaintext passwords in the DB.
Helmetapp.use(helmet()) sets security headers (CSP, X-Frame-Options, HSTS) in a single line.
CORSapp.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.
ValidationPipewhitelist + forbidNonWhitelisted to block mass assignment on unexpected fields.
Input Sanitizationclass-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/passport is 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.

💬 Reader notes

0 notes

Write a note

Share your opinion, a suggestion or a compliment

Latest notes

No notes yet. Be the first to comment!