On this page

Controllers and routes in NestJS

15 min read TextCh. 1 — NestJS Fundamentals

What is a controller?

A controller in NestJS is a class decorated with @Controller() that handles incoming HTTP requests. Each method in the controller is a route handler — a function that responds to a specific HTTP method and URL path combination.

Controllers are responsible for receiving requests, delegating work to services, and returning responses. They should contain no business logic — that belongs in the service layer. This separation of concerns makes controllers thin and easy to test.

Defining routes

NestJS uses method decorators to define routes:

Decorator HTTP method
@Get() GET
@Post() POST
@Put() PUT
@Patch() PATCH
@Delete() DELETE
@Head() HEAD
@Options() OPTIONS
@All() All methods

The @Controller('books') decorator sets the base path for all routes in that controller. A method decorated with @Get(':id') inside a @Controller('books') controller handles GET /books/:id.

Extracting request data

NestJS provides parameter decorators to extract data from the incoming request:

@Param — URL parameters

@Get(':id')
findOne(@Param('id') id: string) {
  return this.booksService.findOne(+id);
}

// Or extract all params as an object
@Get(':category/:slug')
findBySlug(@Param() params: { category: string; slug: string }) {
  return this.booksService.findBySlug(params.category, params.slug);
}

@Query — Query string parameters

@Get()
findAll(
  @Query('page') page: string,
  @Query('limit') limit: string,
  @Query('search') search?: string,
) {
  return this.booksService.findAll({
    page: +page || 1,
    limit: +limit || 10,
    search,
  });
}

@Body — Request body

@Post()
create(@Body() createBookDto: CreateBookDto) {
  return this.booksService.create(createBookDto);
}

// Extract a single field
@Post('bulk')
createMany(@Body('books') books: CreateBookDto[]) {
  return this.booksService.createMany(books);
}

@Headers — Request headers

@Get()
findAll(@Headers('accept-language') lang: string) {
  return this.booksService.findAll(lang);
}

@Req and @Res — Raw Express/Fastify objects

import { Request, Response } from 'express';

@Get('download/:id')
async download(
  @Param('id', ParseIntPipe) id: number,
  @Res() res: Response,
) {
  const file = await this.booksService.getPdfPath(id);
  res.download(file);
}

Note: Using @Res() bypasses NestJS's response interceptors and exception filters. Prefer returning plain objects and letting NestJS handle serialization unless you have a specific reason to access the raw response.

HTTP status codes

By default, NestJS returns 200 OK for all successful responses except POST, which returns 201 Created automatically.

Override the default with @HttpCode():

@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT) // 204
remove(@Param('id', ParseIntPipe) id: number) {
  return this.booksService.remove(id);
}

Use the HttpStatus enum for readability — it lists all standard HTTP status codes as named constants.

Response headers

Add custom headers to responses with the @Header() decorator:

@Get('export')
@Header('Content-Type', 'text/csv')
@Header('Content-Disposition', 'attachment; filename="books.csv"')
exportCsv() {
  return this.booksService.exportCsv();
}

Route wildcards and prefixes

NestJS supports Express-style wildcards in routes:

@Get('ab*cd')
findWithWildcard() {
  // Matches /abcd, /ab_cd, /ab123cd, etc.
}

You can also version your API at the controller level:

// In main.ts
app.enableVersioning({ type: VersioningType.URI });

// In controller
@Controller({ path: 'books', version: '1' })
export class BooksV1Controller {}

@Controller({ path: 'books', version: '2' })
export class BooksV2Controller {}
// Routes: /v1/books and /v2/books

Redirects

@Get('old-path')
@Redirect('/books', HttpStatus.MOVED_PERMANENTLY)
redirectOldPath() {}

Or dynamically:

@Get('docs')
getDocs(@Query('version') version?: string) {
  if (version === 'v2') {
    return { url: 'https://docs.example.com/v2', statusCode: 302 };
  }
  return { url: 'https://docs.example.com/v1' };
}

Sub-domain routing

For multi-tenant applications, route by host:

@Controller({ host: ':account.api.example.com' })
export class AccountController {
  @Get()
  getInfo(@HostParam('account') account: string) {
    return { account };
  }
}

Async route handlers

All route handler methods can be async. NestJS fully supports Promises and RxJS Observables:

// Returning a Promise
@Get()
async findAll(): Promise<Book[]> {
  return this.booksService.findAll();
}

// Returning an Observable
@Get('stream')
findStream(): Observable<Book[]> {
  return this.booksService.findStream();
}

NestJS resolves Promises and Observables automatically before sending the response.

Organizing controller methods

A well-organized books controller should expose these routes:

GET    /books           → findAll (with pagination)
GET    /books/:id       → findOne
GET    /books/search    → search (put BEFORE :id to avoid conflict)
POST   /books           → create
PUT    /books/:id       → update (full replacement)
PATCH  /books/:id       → partialUpdate (partial update)
DELETE /books/:id       → remove

Note the order: @Get('search') must be declared before @Get(':id'). Otherwise NestJS would interpret search as an ID value and route the request to findOne.

Route conflicts and ordering

NestJS matches routes in the order they are declared in the controller. A common mistake:

@Controller('books')
export class BooksController {
  // BAD: :id catches 'popular' before the next route
  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) { ... }

  // This route is never reached when someone requests GET /books/popular
  @Get('popular')
  findPopular() { ... }
}

Fix by placing specific static routes before parameterized ones:

@Get('popular')    // ← first
findPopular() { ... }

@Get(':id')        // ← second
findOne(@Param('id', ParseIntPipe) id: number) { ... }

The @Res passthrough option

If you need access to the raw response object but still want NestJS interceptors and exception filters to work, use the passthrough option:

@Get()
findAll(@Res({ passthrough: true }) res: Response) {
  res.cookie('session', 'abc123');
  return this.booksService.findAll(); // NestJS still handles serialization
}
Route prefix
Set a global route prefix in main.ts with app.setGlobalPrefix('api/v1'). All controller routes will be prefixed automatically, so @Controller('books') becomes /api/v1/books without any changes in the controller.
ParseIntPipe and type safety
URL parameters are always strings. Use ParseIntPipe or ParseUUIDPipe to transform and validate them before they reach your service. Without these pipes, a request to /books/abc would pass a string to a method expecting a number.
import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Param,
  Body,
  Query,
  ParseIntPipe,
  HttpCode,
  HttpStatus,
} from '@nestjs/common';
import { BooksService } from './books.service';
import { CreateBookDto } from './dto/create-book.dto';
import { UpdateBookDto } from './dto/update-book.dto';

@Controller('books')
export class BooksController {
  constructor(private readonly booksService: BooksService) {}

  @Get()
  findAll(@Query('page') page = 1, @Query('limit') limit = 10) {
    return this.booksService.findAll({ page: +page, limit: +limit });
  }

  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.booksService.findOne(id);
  }

  @Post()
  @HttpCode(HttpStatus.CREATED)
  create(@Body() createBookDto: CreateBookDto) {
    return this.booksService.create(createBookDto);
  }

  @Put(':id')
  update(
    @Param('id', ParseIntPipe) id: number,
    @Body() updateBookDto: UpdateBookDto,
  ) {
    return this.booksService.update(id, updateBookDto);
  }

  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  remove(@Param('id', ParseIntPipe) id: number) {
    return this.booksService.remove(id);
  }
}