On this page
Controllers and routes in NestJS
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/booksRedirects
@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 → removeNote 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
}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);
}
}
Sign in to track your progress