NestJS — Enterprise Node.js

REST Best Practices, Serialization & Versioning

16 min Lesson 31 of 48

REST Best Practices, Serialization & Versioning

Building a production-ready NestJS API means going beyond making things work — you must design resources correctly, return the right HTTP status codes, protect sensitive data from leaking into responses, and evolve your API without breaking existing clients. This lesson covers four pillars: RESTful resource design, status codes, serialization with ClassSerializerInterceptor, and API versioning.

RESTful resource design

Good REST design uses nouns, not verbs, in URL paths, and relies on HTTP methods to express actions:

  • GET /users — list all users
  • GET /users/:id — fetch one user
  • POST /users — create a user
  • PATCH /users/:id — partial update (prefer over PUT for partial changes)
  • DELETE /users/:id — remove a user

Nest's @nestjs/common provides @Get, @Post, @Patch, @Put, @Delete decorators to map handlers to these verbs cleanly.

HTTP status codes

Returning the correct status code is part of the API contract. NestJS defaults to 200 for most handlers and 201 for @Post. Override with @HttpCode():

import { Controller, Post, Delete, Param, HttpCode, HttpStatus } from '@nestjs/common'; @Controller('users') export class UsersController { @Post() @HttpCode(HttpStatus.CREATED) // 201 — explicit, self-documenting create(@Body() dto: CreateUserDto) { /* ... */ } @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) // 204 — successful delete, no body remove(@Param('id') id: string) { /* ... */ } }

Common codes to know: 200 OK, 201 Created, 204 No Content, 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 409 Conflict, 422 Unprocessable Entity.

Serialization with ClassSerializerInterceptor

When you return a plain object, every field is sent to the client — including passwords, internal flags, and other sensitive data. NestJS ships ClassSerializerInterceptor (from @nestjs/common) to fix this. It uses class-transformer decorators to control exactly what is exposed.

Install the dependency: npm install class-transformer class-validator

import { Exclude, Expose } from 'class-transformer'; export class UserEntity { id: number; email: string; @Exclude() // never serialized into the response passwordHash: string; @Exclude() internalFlag: boolean; constructor(partial: Partial<UserEntity>) { Object.assign(this, partial); } }

Enable the interceptor globally in main.ts (or per-controller / per-handler with @UseInterceptors):

import { NestFactory, Reflector } from '@nestjs/core'; import { ClassSerializerInterceptor } from '@nestjs/common'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))); await app.listen(3000); } bootstrap();

In the controller, return a new UserEntity(rawUser) (not a plain object) so the interceptor can apply the decorators:

@Get(':id') async findOne(@Param('id') id: string): Promise<UserEntity> { const raw = await this.usersService.findOne(+id); return new UserEntity(raw); // @Exclude fields will be stripped }
Use @Expose() in whitelist mode. If you add excludeExtraneousValues: true to @SerializeOptions(), only fields marked @Expose() are included. This is safer than blacklisting — you opt-in each field rather than remembering to exclude every sensitive one.
Return class instances, not plain objects. ClassSerializerInterceptor only transforms instances of a class with class-transformer metadata. If you return a raw Prisma/TypeORM entity or a plain {}, no transformation occurs and all fields are exposed.

API versioning

Versioning lets you introduce breaking changes without breaking existing clients. NestJS supports four strategies; the two most common are URI versioning (/v1/users) and header versioning (Accept-Version: 1). Enable in main.ts:

import { VersioningType } from '@nestjs/common'; // URI versioning — /v1/... app.enableVersioning({ type: VersioningType.URI }); // OR Header versioning app.enableVersioning({ type: VersioningType.HEADER, header: 'Accept-Version' });

Then decorate controllers or individual handlers:

import { Controller, Get, Version } from '@nestjs/common'; @Controller({ path: 'users', version: '1' }) // all routes under /v1/users export class UsersV1Controller { @Get() findAll() { return 'v1 list'; } } @Controller({ path: 'users', version: '2' }) export class UsersV2Controller { @Get() findAll() { return 'v2 list with pagination'; } @Get(':id') @Version('3') // override to v3 on one handler findOneV3() { return 'v3 single user'; } }
Choose a versioning strategy early. URI versioning is the most visible and cache-friendly. Header versioning keeps URLs clean but requires clients to set a header. Both are supported by NestJS with a single app.enableVersioning() call — there is no need to build it manually.

Summary

Production REST APIs in NestJS rest on four pillars: resource-oriented URLs with correct HTTP verbs, precise status codes (using @HttpCode), serialization (ClassSerializerInterceptor + @Exclude/@Expose to stop sensitive fields from leaking), and versioning (URI or header, enabled with one call). Together these make your API predictable, secure, and evolvable.