← Back to the section

Query parameters are what comes after the ? in a URL: ?status=CONFIRMED&page=2. In NestJS the convention is to read them through a DTO class with validation decorators, rather than parsing them by hand. Let's look at how this works.

Why a DTO and not req.query

You can write @Query('page') page: string and then manually turn the string into a number, check the range, and so on. But when you have five to ten parameters, this turns into boring, repetitive code.

NestJS offers another way: describe all the parameters in a class, add decorators from class-validator, enable ValidationPipe — and the framework does everything itself: converts types, checks constraints, returns a 400 with an error description if something is wrong.

Global ValidationPipe setup

First, configure it once in main.ts:

app.useGlobalPipes(
  new ValidationPipe({
    transform: true,    // string → number/boolean/enum automatically
    whitelist: true,    // strips out extraneous parameters
  }),
);

The transform: true flag is critically important: without it, all query parameters arrive as strings and @IsInt() won't work.

Naming: camelCase

In a URL the convention is to write ?customerId=..., not ?customer_id=.... TypeScript code also uses camelCase — no aliases are needed, the parameter name matches the field name.

export class OrdersQueryDto {
  @IsOptional()
  @IsEnum(OrderStatus)
  status?: OrderStatus;         // ?status=CONFIRMED

  @IsOptional()
  @IsString()
  customerId?: string;          // ?customerId=550e...

  @IsOptional()
  @IsDateString()
  createdAtFrom?: string;       // ?createdAtFrom=2026-01-01T00:00:00Z

  @IsOptional()
  @IsDateString()
  createdAtTo?: string;         // ?createdAtTo=2026-12-31T23:59:59Z
}

A common mistake is to write customer_id or CustomerID. Both options break the convention: the first is Python/Ruby style, the second is PascalCase, which is reserved for types.

Numeric parameters: you need @Type

HTTP passes everything as strings, so numbers need to be explicitly converted:

export class ProductsQueryDto {
  @IsOptional()
  @Type(() => Number)
  @IsNumber()
  @Min(0)
  priceFrom?: number;           // ?priceFrom=100

  @IsOptional()
  @Type(() => Number)
  @IsNumber()
  @Max(1000000)
  priceTo?: number;             // ?priceTo=5000
}

@Type(() => Number) tells class-transformer: "turn the string into a number before validation". Without it, @IsInt() and @IsNumber() will always return an error — they receive a string instead of a number.

Pagination: page always starts at 1

Page parameters are put into a separate DTO and reused everywhere pagination is needed:

export class PaginationDto {
  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  page: number = 1;

  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(100)
  size: number = 20;
}

page starts at 1, not 0. ?page=0 is an error, the validator will reject it. This matches the way users think: "the first page" is page number one.

In the controller we accept both DTOs in parallel:

@Controller('orders')
export class OrdersController {
  @Get()
  async findAll(
    @Query() query: OrdersQueryDto,
    @Query() pagination: PaginationDto,
  ) {
    return this.ordersService.findAll(query, pagination);
  }
}

The response contains the data and pagination metadata:

{
  "content": [...],
  "page": 1,
  "size": 20,
  "totalElements": 243,
  "totalPages": 13
}

Cursor pagination

Offset pagination (page=1, page=2, ...) works well for small volumes. But with large tables, OFFSET 10000 forces the database to skip ten thousand rows — that's slow. Cursor pagination solves this problem.

A cursor is an opaque token that the server returns with each page. The client passes it back to get the next batch of data. What's inside the token is the server's business (usually base64 of the last element's identifier).

export class CursorPaginationDto {
  @IsOptional()
  @IsString()
  cursor?: string;             // opaque token from the server

  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(100)
  size: number = 20;
}

Response:

{
  "content": [...],
  "nextCursor": "eyJpZCI6IjU1MG...",
  "hasMore": true
}

The client takes nextCursor and passes it in the next request: ?cursor=eyJpZCI6IjU1MG.... There's no need to parse the token's contents on the client — that breaks the contract and ties the client to the server's internal implementation.

Sorting

Sorting is passed as a single parameter: field and direction separated by a comma.

export class SortQueryDto {
  @IsOptional()
  @IsString()
  @Matches(/^[a-zA-Z]+,(asc|desc)$/)
  sort?: string;               // ?sort=createdAt,desc
}

On the server side we parse the string:

function parseSort(sort?: string): { field: string; direction: 'asc' | 'desc' } | undefined {
  if (!sort) return undefined;
  const [field, direction] = sort.split(',');
  return { field, direction: direction as 'asc' | 'desc' };
}

For simple text search, the q parameter is used:

export class SearchQueryDto {
  @IsOptional()
  @IsString()
  @MinLength(2)
  q?: string;                  // ?q=laptop
}

@Get()
async findAll(@Query() query: SearchQueryDto) {
  if (query.q) return this.productsService.search(query.q);
  return this.productsService.findAll();
}

Multiple values: repeat the parameter

If you need to pass several values of a single parameter, repeat it:

?status=CONFIRMED&status=SHIPPED

Writing ?status=CONFIRMED,SHIPPED with a comma is wrong: different clients parse this differently, and the server gets a single string instead of an array.

export class StatusFilterDto {
  @IsOptional()
  @IsArray()
  @IsEnum(OrderStatus, { each: true })
  @Transform(({ value }) => (Array.isArray(value) ? value : [value]))
  status?: OrderStatus[];      // ?status=CONFIRMED&status=SHIPPED
}

@Transform here is needed for the case when a single value is passed: Express parses it as a string, not an array of one element.

Complex search via POST /search

When there are many filters — dates, ranges, nested conditions — a long URL becomes inconvenient and may exceed browser limits. In such cases POST /resources/search with a JSON body is used:

export class OrderSearchDto {
  @IsOptional()
  @IsString()
  customerId?: string;

  @IsOptional()
  @IsArray()
  @IsEnum(OrderStatus, { each: true })
  statuses?: OrderStatus[];

  @IsOptional()
  @IsDateString()
  createdAtFrom?: string;

  @IsOptional()
  @Type(() => Number)
  @Min(0)
  totalAmountMin?: number;
}

@Controller('orders')
export class OrdersController {
  @Post('search')
  @HttpCode(200)
  async search(@Body() dto: OrderSearchDto) {
    return this.ordersService.search(dto);
  }
}

@HttpCode(200) is important: NestJS responds to a POST with code 201 by default, but a search is a read operation, not a resource creation.

In short

  • ValidationPipe({ transform: true }) globally — types are converted from strings automatically.
  • Parameter names are camelCase: customerId, createdAtFrom.
  • Numeric parameters require @Type(() => Number), otherwise validation will fail.
  • page starts at 1; page=0 is an error.
  • Arrays are passed by repeating the parameter: ?status=A&status=B, not with a comma.
  • A cursor is an opaque token; the client passes it as is, without parsing it.
  • Complex multi-filter search — POST /resources/search with a JSON body.

Further reading

  • JSON and response format — the content structure + pagination in the response.
  • URLs and resources — path format and @Controller.
  • Error handling — how NestJS returns a 400 on invalid parameters.