By NestJSJuly 16, 2026·12 min read

Building Your First NestJS API: From Zero to a Validated Endpoint

NestJS validation from zero: ValidationPipe and DTOs, then nested objects, query strings, a custom validator and your own error format.

NestJS validation runs through ValidationPipe. You describe the allowed request body as a DTO class with class-validator decorators, switch the pipe on once in main.ts, and any request that doesn't match gets a 400 Bad Request before your controller code runs. This post builds that from an empty folder, then goes past the basics: nested objects and arrays, query strings, a custom validator, and your own error format.

Send { "a": "hello", "b": 25 } to an unvalidated NestJS endpoint that adds two numbers and you get back "hello25". By the end of this post that same request gets a clean 400 error instead. Along the way you'll install NestJS, write your first GET and POST routes, and test each one in Postman.

Setting up

You need Node.js installed first (NestJS 12 needs v20.19+ or v22.12+, and the current LTS release is the safe pick). Check with node --version. From there, install the NestJS CLI globally with npm install -g @nestjs/cli. The CLI exists so you never have to hand-wire the boilerplate: run nest new my-server, pick npm as your package manager, and it scaffolds a working project for you. Newer CLI versions also ask whether you want CommonJS or ESM. Pick CommonJS to match the code in this post.

terminal
node --version
npm install -g @nestjs/cli
nest new my-server
cd my-server
npm run start:dev

Inside, you'll find src/main.ts (the entry point that boots everything), src/app.module.ts (the root module), src/app.controller.ts (your first routes), and src/app.service.ts (business logic). The last command in the block above starts the server on port 3000 in watch mode. That means it restarts by itself every time you save a file, so you're not stopping and starting it after every change.

Inside main.ts, two lines do the heavy lifting: NestFactory.create(AppModule) boots the whole application by reading your module tree, and app.listen(3000) tells it to start accepting connections on port 3000. Everything else in NestJS builds on top of that boot process.

Your first GET routes

Open src/app.controller.ts. This is where routes live. @Controller() with nothing inside the parentheses means routes start right at the root path. Each method below it gets its own @Get() decorator: a bare @Get() handles GET /, while @Get('hello') handles GET /hello. Return a string or a plain object from the method and NestJS serializes it to JSON (or plain text) as the response body. No manual res.send() required.

src/app.controller.ts
import { Controller, Get } from '@nestjs/common';

@Controller()
export class AppController {
  @Get()
  getRoot(): string {
    return 'Welcome to my NestJS server';
  }

  @Get('hello')
  getHello() {
    return {
      message: 'Hello from NestJS',
      timestamp: new Date().toISOString(),
    };
  }
}

You can test this right away in Postman: create a new GET request to http://localhost:3000/hello and hit Send. You should get back a JSON object with a message and a timestamp. That round trip is the whole mental model for every route you'll write. You send a request, the router matches it to a controller method, the method returns a value, and NestJS turns it into an HTTP response.

Building a real endpoint: accepting input

GET routes are fine for reading things. But once the client needs to send you data, like two numbers to add together, you need a POST route, because that data travels in the request body, not the URL. Instead of cramming this into app.controller.ts, the NestJS convention is a dedicated module per feature. Running nest generate module math, nest generate controller math and nest generate service math scaffolds a self-contained math/ folder with its own module, controller, and service files.

terminal
nest generate module math
nest generate controller math
nest generate service math

The service holds the logic: a plain sum(a, b) method that adds two numbers. The controller's @Post('sum') method uses @Body() to pull the JSON payload out of the request, calls the service, and returns the result. Test it in Postman with method POST, URL http://localhost:3000/math/sum, and a raw JSON body like { "a": 10, "b": 25 }. You should get back { "a": 10, "b": 25, "sum": 35 }, with status 201 Created. NestJS uses 201 for every POST by default, and 200 for other methods. Add @HttpCode(200) to the method if you want 200 instead.

math/math.service.ts + math.controller.ts
// math/math.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class MathService {
  sum(a: number, b: number): number {
    return a + b;
  }
}

// math/math.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { MathService } from './math.service';

@Controller('math')
export class MathController {
  constructor(private readonly mathService: MathService) {}

  @Post('sum')
  calculateSum(@Body() body: { a: number; b: number }) {
    const sum = this.mathService.sum(body.a, body.b);
    return { a: body.a, b: body.b, sum };
  }
}

Sequence: Postman sends POST /math/sum with a 10 and b 25. The NestJS router calls MathController.calculateSum, which calls MathService.sum and gets 35, and the response is 201 Created with a, b and sum 35 as JSON.

A POST request through the sum endpoint

Why this breaks the moment someone sends bad data

Try sending { "a": "hello", "b": 25 } to that same endpoint right now, with no validation in place. You'll get back { "a": "hello", "b": 25, "sum": "hello25" } — JavaScript happily did string concatenation instead of addition, because nothing ever checked that a was actually a number. This is the entire reason validation exists: without it, your server accepts anything, and "anything" eventually includes empty strings that shouldn't be saved, missing fields that crash a calculation, and malformed input designed specifically to break your API.

How NestJS validation actually works

NestJS leans on two libraries together. class-transformer converts the raw, untyped JSON body into an instance of your DTO class. class-validator then runs decorator-based rules against that instance: @IsNumber(), @IsNotEmpty(), @Min(), @Max(), and many more. If every rule passes, the request reaches your controller method as normal. If anything fails, NestJS stops the request and returns a 400 Bad Request listing exactly what was wrong. Your controller method never runs.

The raw JSON body is turned into a DTO instance by class-transformer, then class-validator runs the decorator rules. If all pass, the controller method runs; if any fail, the client gets 400 Bad Request with a list of errors.

How ValidationPipe checks a request body

Wiring it up

Install the two libraries with npm install class-validator class-transformer, then enable validation for the whole app in main.ts with app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })). whitelist strips any field not defined in your DTO; forbidNonWhitelisted rejects the request outright if it sees a field it doesn't recognise (instead of quietly dropping it); transform hands your controller a real SumDto instance instead of a plain object. It also turns route and query params, which always arrive as strings, into numbers when they're typed as number.

src/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    }),
  );

  await app.listen(3000);
}
bootstrap();

Now replace the inline { a: number; b: number } type with a SumDto class. Put @IsDefined(), @IsNumber(), @Min(-1000000) and @Max(1000000) on both a and b. The required and number checks get custom messages so the error response is readable; @Min and @Max keep class-validator's default wording. Leave out @Type(() => Number) here. JSON already sends numbers as numbers, and @Type would quietly turn "" into 0 and true into 1, so they'd pass. Then point the controller at it with calculateSum(@Body() body: SumDto), and validation is wired in.

math/dto/sum.dto.ts
import { IsDefined, IsNumber, Max, Min } from 'class-validator';

export class SumDto {
  @IsDefined({ message: 'Field "a" is required' })
  @IsNumber({}, { message: '"a" must be a number' })
  @Min(-1000000)
  @Max(1000000)
  a: number;

  @IsDefined({ message: 'Field "b" is required' })
  @IsNumber({}, { message: '"b" must be a number' })
  @Min(-1000000)
  @Max(1000000)
  b: number;
}
math/math.controller.ts
import { SumDto } from './dto/sum.dto';

// ...inside MathController
@Post('sum')
calculateSum(@Body() body: SumDto) {
  const sum = this.mathService.sum(body.a, body.b);
  return { a: body.a, b: body.b, sum };
}

Watching it reject bad requests

Send { "a": "hello", "b": 25 } now and instead of a broken "hello25", you get a clean 400. ValidationPipe lists every rule that failed, so "a" must be a number comes back along with the min and max messages, because a string fails those too (full response below). Send { "b": 25 } with a missing entirely and the list includes Field "a" is required. Send an extra field like { "a": 10, "b": 25, "hack": "drop table users" } and forbidNonWhitelisted rejects the whole request with property hack should not exist — which is also a small, free layer of protection against clients sending fields you never intended to accept.

400 response for { "a": "hello", "b": 25 }
{
  "message": [
    "a must not be greater than 1000000",
    "a must not be less than -1000000",
    "\"a\" must be a number"
  ],
  "error": "Bad Request",
  "statusCode": 400
}

ValidationPipe options worth knowing

whitelist, forbidNonWhitelisted and transform are the three most apps turn on. A few more from the NestJS validation docs are worth knowing before you ship:

  • –stopAtFirstError: true reports only the first failing rule per field. For { "a": "hello", "b": 25 } you'd get just "a must not be greater than 1000000", which is the least helpful of the three, so think about decorator order if you turn this on.
  • –disableErrorMessages: true hides the list of failed rules from the client. Some teams do this for public APIs so they don't describe their rules to strangers. The trade-off is that your own frontend can't show field errors either.
  • –errorHttpStatusCode: 422 sends 422 Unprocessable Entity instead of the default 400, if your API style prefers it.
  • –exceptionFactory replaces the error response completely. There's a full example further down.

One combination to be careful with: whitelist without forbidNonWhitelisted strips unknown fields silently. That's friendly to old clients that still send a field you removed, but it also hides typos. A client sending "emial" gets a success response and an empty email, with no hint why. In development, forbidNonWhitelisted makes those mistakes loud.

Validating nested objects and arrays with @ValidateNested and @Type

Say the endpoint should add many pairs in one call: { "pairs": [{ "a": 1, "b": 2 }, { "a": 10, "b": 25 }] }. You can reuse SumDto for each item, but it takes two decorators on the array property, and both matter. @ValidateNested({ each: true }) tells class-validator to check every item against its own rules. @Type(() => SumDto), from class-transformer, tells the pipe which class to turn each plain JSON object into. Without it, the items stay plain objects and there are no rules to check them against.

math/dto/batch-sum.dto.ts
import { Type } from 'class-transformer';
import { ArrayMaxSize, ArrayNotEmpty, IsArray, ValidateNested } from 'class-validator';
import { SumDto } from './sum.dto';

export class BatchSumDto {
  @IsArray()
  @ArrayNotEmpty()
  @ArrayMaxSize(100)
  @ValidateNested({ each: true })
  @Type(() => SumDto)
  pairs: SumDto[];
}
math/math.controller.ts
import { BatchSumDto } from './dto/batch-sum.dto';

// ...inside MathController
@Post('batch')
calculateBatch(@Body() body: BatchSumDto) {
  return body.pairs.map(({ a, b }) => ({ a, b, sum: this.mathService.sum(a, b) }));
}

Send a bad second item, { "pairs": [{ "a": 1, "b": 2 }, { "a": "x", "b": 2 }] }, and every message is prefixed with its path, so you can see exactly which item failed. ArrayMaxSize(100) is there because without a cap, one request can ask your server to do unbounded work.

400 response for a bad second pair
{
  "message": [
    "pairs.1.a must not be greater than 1000000",
    "pairs.1.a must not be less than -1000000",
    "pairs.1.\"a\" must be a number"
  ],
  "error": "Bad Request",
  "statusCode": 400
}

That last line shows a quirk: Nest glues the path onto the front of your custom message, so a message that starts with the field name reads oddly. The custom error format below fixes that. Now leave out @Type and send a perfectly valid body. With whitelist and forbidNonWhitelisted on, you get "pairs.0.property a should not exist": each item is a plain object, so as far as the whitelist knows it has no allowed fields at all. With whitelist alone, the items are quietly emptied to {}. That's usually the cause when people say @ValidateNested isn't working.

When the body itself is an array

If the client sends a bare array, @Body() body: SumDto[] is not validated at all. TypeScript doesn't record the item type of an array, so the pipe only sees Array, and [{ "a": "x" }] goes straight through. Either wrap the array in a DTO like BatchSumDto, or use ParseArrayPipe, which the docs recommend for this case:

math/math.controller.ts
import { Body, ParseArrayPipe, Post } from '@nestjs/common';
import { SumDto } from './dto/sum.dto';

// ...inside MathController
@Post('batch-array')
calculateBatchArray(
  @Body(new ParseArrayPipe({ items: SumDto, whitelist: true, forbidNonWhitelisted: true }))
  pairs: SumDto[],
) {
  return pairs.map(({ a, b }) => ({ a, b, sum: this.mathService.sum(a, b) }));
}

Validating query strings with a DTO

Query strings are the one place you do want @Type(() => Number). Everything in ?a=3&b=4 arrives as a string, and transform: true only converts the property types of a DTO when you tell it how. Here's GET /math/multiply?a=3&b=4:

math/dto/multiply-query.dto.ts + math.controller.ts
// math/dto/multiply-query.dto.ts
import { Type } from 'class-transformer';
import { IsNumber } from 'class-validator';

export class MultiplyQueryDto {
  @Type(() => Number)
  @IsNumber()
  a: number;

  @Type(() => Number)
  @IsNumber()
  b: number;
}

// math/math.controller.ts (add Get and Query to the @nestjs/common import)
@Get('multiply')
multiply(@Query() query: MultiplyQueryDto) {
  return { a: query.a, b: query.b, product: query.a * query.b };
}

?a=abc and a missing a are both rejected, because Number("abc") is NaN and @IsNumber() refuses NaN by default. But ?a=&b=4 passes with a = 0, because Number("") is 0. If an empty value should count as missing, use @Transform instead of @Type and only convert strings that have something in them:

math/dto/multiply-query.dto.ts
import { Transform } from 'class-transformer';
import { IsNumber } from 'class-validator';

const toNumber = ({ value }: { value: unknown }) =>
  typeof value === 'string' && value.trim() !== '' ? Number(value) : value;

export class MultiplyQueryDto {
  @Transform(toNumber)
  @IsNumber()
  a: number;

  @Transform(toNumber)
  @IsNumber()
  b: number;
}

Writing a custom validator

Built-in decorators check one field at a time. Rules that compare two fields, like "max must be greater than min", need a custom decorator. class-validator's registerDecorator() turns a plain function into one (see the class-validator custom decorator docs). The validator gets the value and the whole object, so it can read the other field:

common/is-greater-than.decorator.ts
import { registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator';

export function IsGreaterThan(property: string, validationOptions?: ValidationOptions) {
  return function (object: object, propertyName: string) {
    registerDecorator({
      name: 'isGreaterThan',
      target: object.constructor,
      propertyName,
      constraints: [property],
      options: validationOptions,
      validator: {
        validate(value: unknown, args: ValidationArguments) {
          const [relatedPropertyName] = args.constraints;
          const relatedValue = (args.object as Record<string, unknown>)[relatedPropertyName];
          return typeof value === 'number' && typeof relatedValue === 'number' && value > relatedValue;
        },
        defaultMessage(args: ValidationArguments) {
          return `${args.property} must be greater than ${args.constraints[0]}`;
        },
      },
    });
  };
}
math/dto/range.dto.ts
import { IsInt } from 'class-validator';
import { IsGreaterThan } from '../../common/is-greater-than.decorator';

export class RangeDto {
  @IsInt()
  min: number;

  @IsInt()
  @IsGreaterThan('min')
  max: number;
}

{ "min": 10, "max": 5 } now fails with "max must be greater than min". validate() can also return a Promise, so async checks work. But for checks that need the database, like "this email is already taken", it's usually simpler to check in the service and throw a ConflictException. That keeps database access out of your DTOs, and the 409 status tells the client something different from a malformed request.

Custom validation error format with exceptionFactory

The default response is a flat list of sentences. That's fine in Postman, but a frontend form wants errors keyed by field so it can put each message under the right input. exceptionFactory receives class-validator's ValidationError tree (each error has a property, its failed constraints, and children for nested objects) and returns the exception to throw:

common/validation-exception.factory.ts
import { BadRequestException, ValidationError } from '@nestjs/common';

// Turns class-validator's error tree into { "pairs.1.a": ["..."], "b": ["..."] }
function collect(errors: ValidationError[], parent = '', out: Record<string, string[]> = {}) {
  for (const error of errors) {
    const path = parent ? `${parent}.${error.property}` : error.property;
    if (error.constraints) {
      out[path] = Object.values(error.constraints);
    }
    if (error.children?.length) {
      collect(error.children, path, out);
    }
  }
  return out;
}

export function validationExceptionFactory(errors: ValidationError[]) {
  return new BadRequestException({
    statusCode: 400,
    error: 'Validation failed',
    fields: collect(errors),
  });
}

Pass it in with new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true, exceptionFactory: validationExceptionFactory }). The bad batch from earlier now comes back like this:

400 response with the custom format
{
  "statusCode": 400,
  "error": "Validation failed",
  "fields": {
    "pairs.1.a": [
      "a must not be greater than 1000000",
      "a must not be less than -1000000",
      "\"a\" must be a number"
    ]
  }
}

Register the pipe with APP_PIPE so your tests get it too

useGlobalPipes() lives in main.ts, and main.ts doesn't run in an end-to-end test that builds the app from AppModule with Test.createTestingModule(). The tests then hit an API with no validation and pass when they shouldn't. Registering the pipe as an APP_PIPE provider puts it inside the module, so every app built from AppModule gets it. Per the pipes docs, it's global no matter which module registers it, and it can inject dependencies, which useGlobalPipes() can't. If you do this, remove the useGlobalPipes() call so validation doesn't run twice.

src/app.module.ts
import { Module, ValidationPipe } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';
import { validationExceptionFactory } from './common/validation-exception.factory';
import { MathModule } from './math/math.module';

@Module({
  imports: [MathModule],
  providers: [
    {
      provide: APP_PIPE,
      useValue: new ValidationPipe({
        whitelist: true,
        forbidNonWhitelisted: true,
        transform: true,
        exceptionFactory: validationExceptionFactory,
      }),
    },
  ],
})
export class AppModule {}

Why isn't my NestJS validation working?

When a bad request sails through, it's almost always one of these:

  • –The parameter is typed with an interface or an inline type, like the { a: number; b: number } we started with. ValidationPipe only validates classes, because types and interfaces don't exist at runtime.
  • –The DTO is imported with import type. That import is erased at runtime, so the pipe never sees the class.
  • –A nested property has @ValidateNested() but no @Type(). See the nested section above.
  • –The body is a bare array typed as SumDto[]. Use ParseArrayPipe or a wrapper DTO.
  • –The pipe is registered in main.ts, but you're testing an app built in a test file. Use APP_PIPE.
  • –@IsOptional() skips every rule when the value is null as well as undefined, so { "nick": null } passes @IsOptional() @IsString(). If a field may be left out but must never be null, use @ValidateIf((_object, value) => value !== undefined) instead of @IsOptional().
  • –Two copies of class-validator are installed, for example one pulled in by another package. Decorators register on one copy and the pipe reads the other. Check with npm ls class-validator.

Common questions

What's the difference between whitelist and forbidNonWhitelisted?

whitelist removes fields that have no decorator in the DTO and lets the request continue. forbidNonWhitelisted, used together with whitelist, rejects the request with a 400 that names each unexpected field.

Do I need class-transformer for NestJS validation?

Yes. ValidationPipe uses class-transformer to turn the JSON into an instance of your DTO before class-validator checks it, so install both. You'll also import @Type and @Transform from it for nested objects and query strings.

Can I use Zod instead of class-validator?

Yes. Current NestJS ships a StandardSchemaValidationPipe in @nestjs/common that validates against Zod, Valibot or ArkType schemas instead of decorated classes, and it can run alongside ValidationPipe. The validation docs show how to attach a schema.

How do I return 422 instead of 400 for validation errors?

Set errorHttpStatusCode: 422 in the ValidationPipe options. If you also use a custom exceptionFactory, throw an UnprocessableEntityException from it instead of a BadRequestException, because the factory decides the exception.

Postman cheatsheet for what you just built

  • –GET localhost:3000/ (no body) returns a welcome message
  • –GET localhost:3000/hello (no body) returns a hello JSON object
  • –POST localhost:3000/math/sum with { "a": 10, "b": 25 } returns the validated sum

What's next

This tiny endpoint already has the shape of a real one: a module, a controller, a service and a DTO. Scale this pattern up and you'll find the same pieces in every NestJS app, plus a few more. I'm a frontend engineer who builds the UI on top of APIs like this. If you need that, see my work or get in touch.

The NestJS auth post builds signup and login on DTOs like these. For where this all fits, see the backend developer roadmap.