Controllers and Routing
- nestjs
- nodejs
- typescript
- backend
- api
- rest-api
- software-architecture
Last time, in Providers and Custom Providers, we looked at how NestJS's dependency injection container decides what gets built and when. That article was about the plumbing behind the scenes. This one is about the part of the app your users' HTTP requests actually touch first: the controller.
This is part of the NestJS Production Guide series. If you've used Express or Fastify directly before, controllers will feel familiar at first glance and then subtly different once you start relying on the decorator metadata NestJS builds on top.
Controllers get treated as boilerplate more often than they should. In practice, how you shape your controllers decides how easy your API is to extend six months from now, and how much accidental logic creeps into a layer that should stay dumb on purpose.
The controller's one job
A controller's job is narrow: take an incoming HTTP request, hand it to the right service, and shape the response. Nothing else. NestJS enforces this loosely through convention rather than the compiler, so the discipline is on you.
// src/modules/invoices/invoices.controller.ts
import { Controller, Get, Param } from '@nestjs/common';
import { InvoicesService } from './invoices.service';
@Controller('invoices')
export class InvoicesController {
constructor(private readonly invoices: InvoicesService) {}
@Get(':id')
getOne(@Param('id') id: string) {
return this.invoices.findById(id);
}
}
The @Controller('invoices') decorator sets a route prefix, so every handler in this class lives under /invoices. NestJS resolves this at startup by walking the module tree and registering each decorated method against the underlying HTTP adapter, whether that's Express or Fastify. You never touch the adapter's router directly, which is exactly the point: the framework gives you a declarative surface and keeps the wiring out of your way.
The reason this matters in production is less about the framework and more about team habits. When a controller only orchestrates, a new engineer can open the file and understand the entire surface area of an endpoint in ten seconds: what it accepts, what it returns, and which service owns the real work. When a controller starts doing validation, business rules, and database calls inline, that clarity disappears, and it disappears exactly where new hires and future you need it most: at the edges of the system, where requests come in.
Defining routes with decorators
NestJS gives you one decorator per HTTP method: @Get, @Post, @Put, @Patch, @Delete, plus @Head and @Options for the less common cases. Each accepts an optional path that's appended to the controller's prefix.
// src/modules/invoices/invoices.controller.ts
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { InvoicesService } from './invoices.service';
import { CreateInvoiceDto } from './dto/create-invoice.dto';
import { UpdateInvoiceDto } from './dto/update-invoice.dto';
import { ListInvoicesQueryDto } from './dto/list-invoices-query.dto';
@Controller('invoices')
export class InvoicesController {
constructor(private readonly invoices: InvoicesService) {}
@Get()
list(@Query() query: ListInvoicesQueryDto) {
return this.invoices.list(query);
}
@Get(':id')
getOne(@Param('id') id: string) {
return this.invoices.findById(id);
}
@Post()
create(@Body() dto: CreateInvoiceDto) {
return this.invoices.create(dto);
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateInvoiceDto) {
return this.invoices.update(id, dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.invoices.remove(id);
}
}
Handlers can return a plain value, a promise, or an observable, and NestJS resolves any of the three the same way before serializing the response. That flexibility is nice, but it's also easy to lean on carelessly: an async handler that awaits three sequential calls it didn't need to serialize is a controller quietly doing work it should have delegated. If a handler's body is more than a couple of lines, that's usually a sign the logic belongs in the service. The reason has nothing to do with style. The service is what's testable in isolation and reusable from a queue worker or a CLI script later.
Extracting data from the request
Route parameters, query strings, and request bodies each get their own decorator: @Param, @Query, and @Body. Under the hood, these read from the request object the underlying HTTP adapter already parsed, so there's no manual req.params or req.body reaching in.
The detail worth internalizing here is that everything arriving through these decorators is a string (or, for the body, whatever JSON.parse produced) until you tell NestJS otherwise. Route parameters in particular are always strings, even when the value looks like a number. Coerce them explicitly rather than trusting implicit conversion further down the call chain.
// src/modules/invoices/invoices.controller.ts
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common';
import { InvoicesService } from './invoices.service';
@Controller('invoices')
export class InvoicesController {
constructor(private readonly invoices: InvoicesService) {}
@Get(':id')
getOne(@Param('id', ParseUUIDPipe) id: string) {
return this.invoices.findById(id);
}
}
ParseUUIDPipe here rejects a malformed ID before the handler body even runs, returning a 400 automatically. That's a small thing, but it means InvoicesService.findById can assume it always receives a syntactically valid UUID and doesn't need to defend against garbage input on every call site. Pushing that kind of validation to the edge, right where the decorator already sits, keeps the service layer focused on business rules instead of input hygiene. We'll go deeper on this with full DTOs and validation pipes in the next article.
Route ordering and the conflicts it causes
NestJS registers routes in the order the framework encounters them, and it matches incoming requests against that list top to bottom, first match wins. This is where a specific, common bug shows up: a dynamic segment defined before a static one shadows it.
@Controller('invoices')
export class InvoicesController {
@Get(':id')
getOne(@Param('id') id: string) {
// matches /invoices/anything, including /invoices/summary
}
@Get('summary')
getSummary() {
// unreachable: getOne already claimed this path
}
}
A request to /invoices/summary never reaches getSummary, because :id matches any segment and it was registered first. The fix is simply to order static routes before dynamic ones within a controller.
@Controller('invoices')
export class InvoicesController {
@Get('summary')
getSummary() {
return this.invoices.summary();
}
@Get(':id')
getOne(@Param('id') id: string) {
return this.invoices.findById(id);
}
}
Most path-based routers behave this way, Express and Fastify included, so NestJS is just following convention here. Still, it's easy to forget once a controller has grown past a handful of routes. The bug is quiet: no error, no crash, just a handler silently receiving traffic meant for another one. If your team ends up with controllers that mix static and parameterized paths often, it's worth a lint rule or a code review habit rather than relying on everyone remembering the ordering rule by heart.
When to reach for @Req and @Res, and why you mostly shouldn't
NestJS lets you drop down to the raw request and response objects with @Req and @Res. Occasionally you need this: setting a raw cookie header with options the framework doesn't expose a shortcut for, or streaming a file response. But using @Res changes the contract for that handler in a way that's easy to miss.
Once you inject @Res, NestJS assumes you're taking full manual control of the response, and it stops applying its own response handling for that route unless you explicitly call passthrough: true. That means your global exception filters, response interceptors, and the automatic serialization NestJS normally does all get bypassed for that one handler. A team convention that "our interceptor logs every response" quietly breaks for any route that opted into @Res without passthrough, and nobody notices until an incident review turns up a gap in the logs.
import { Controller, Get, Res } from '@nestjs/common';
import type { Response } from 'express';
@Controller('reports')
export class ReportsController {
@Get('export.csv')
export(@Res({ passthrough: true }) res: Response) {
res.header('Content-Type', 'text/csv');
return this.reportsService.buildCsv();
}
}
passthrough: true here tells NestJS you only want to set a header, and the framework should still handle serialization and the interceptor chain as usual. Reach for @Res without passthrough only when you genuinely need to end the response yourself. Make it a deliberate, occasional call, and let NestJS handle the response the rest of the time.
Key takeaways
- A controller's only job is to translate an HTTP request into a call on a service and shape the result. Business logic in the handler body is a smell, not a convenience.
- `@Controller`, `@Get`, `@Post`, and friends are declarative metadata; NestJS wires them into the underlying Express or Fastify router at startup.
- Route parameters and query values arrive as strings. Use pipes like `ParseUUIDPipe` or `ParseIntPipe` to coerce and validate at the edge instead of trusting implicit conversion downstream.
- Routes match in registration order, so a dynamic segment like `:id` registered before a static path like `summary` will silently swallow requests meant for the static route.
- `@Res` without `passthrough: true` bypasses NestJS's own response handling, including interceptors and exception filters, for that one route. Use it deliberately, not by habit.
Frequently asked questions
What is the difference between a controller and a service in NestJS?
A controller handles the HTTP layer: routing, extracting request data, and shaping the response. A service holds the actual business logic and is framework-agnostic in the sense that it doesn't know or care whether it was called from an HTTP request, a queue job, or a test. Controllers should stay thin and delegate to services.
Why does my NestJS route return 404 even though the handler exists?
Check the controller's prefix and the order routes are declared in. A more general route, especially one with a dynamic parameter like `:id`, registered earlier in the same controller will match first and prevent a later, more specific route from ever being reached.
How do I get query parameters in a NestJS controller?
Use the `@Query()` decorator on a handler argument. Without an argument name it returns the whole query object; with a name, `@Query('page')`, it returns just that value. Both arrive as strings, so validate and coerce types explicitly with a DTO or a pipe.
Can a NestJS handler return a Promise directly?
Yes. NestJS awaits any returned promise (and subscribes to any returned Observable) before serializing the response, so `async` handlers work without extra wiring. Keep the awaited work itself inside the service, though, so the handler stays a thin pass-through.
When should I use @Res instead of just returning a value from the handler?
Only when you need something NestJS's normal response handling doesn't expose, like streaming a file or setting response options the framework doesn't wrap. Even then, use `@Res({ passthrough: true })` so interceptors and exception filters still run. Bypassing that entirely should be rare and deliberate.
Does NestJS support route versioning through controllers?
Yes, through a dedicated versioning feature (URI, header, or media-type based) rather than by hand-rolling prefixes. That's broad enough to deserve its own treatment, which we cover later in this series.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.