Controllers & Routing
In Phase 1 you got an app running and saw a controller answer a request. Now let's slow down and really look at the thing doing the answering, because once it clicks, the rest of Nest stops feeling like a pile of decorators and starts feeling like a layout you can predict.
The mental model: a controller is a class of routes
Here's the one idea to hold onto: a controller is a plain class whose methods are routes. That's it. The class says "I'm in charge of this slice of the URL space." Each method says "I handle this HTTP verb on this sub-path." The decorators are just labels Nest reads to wire the method to a URL.
If you've written Express, you've done this with app.get('/tasks', handler). Nest is the same routing — it literally runs on Express underneath — but instead of registering handlers by hand, you describe them with decorators and let Nest do the registration. The payoff is structure: related routes live together in one named class, and there's an obvious place for every endpoint.
📝 A second idea rides along with the first: parameter decorators inject the pieces of the request you ask for. You don't reach into a big
reqobject and dig outreq.params.id— you write@Param('id') id: stringand Nest hands you exactly that. The request gets disassembled into the arguments you declared.
We'll build these ideas around the tasks API we're growing through this guide. A task is just { id, title, done }. By the end of the guide it'll have a real service and database behind it; for now we're focused on the HTTP shell — the controller — so the method bodies will stay sketchy on purpose.
@Controller and the route decorators
@Controller('tasks') declares a controller whose base path is /tasks. Every route method inside it is relative to that base. The method decorators — @Get(), @Post(), @Put(':id'), @Patch(':id'), @Delete(':id') — name the HTTP verb, and their argument is the sub-path appended to the base.
import { Controller, Get, Post, Put, Patch, Delete } from '@nestjs/common';
@Controller('tasks')
export class TasksController {
@Get() // GET /tasks
findAll() { /* ... */ }
@Get(':id') // GET /tasks/:id
findOne() { /* ... */ }
@Post() // POST /tasks
create() { /* ... */ }
@Patch(':id') // PATCH /tasks/:id
update() { /* ... */ }
@Delete(':id') // DELETE /tasks/:id
remove() { /* ... */ }
}
What just happened: the 'tasks' on @Controller set the base path once, and each method decorator added a verb plus an optional sub-path. @Get() with no argument means "the base path itself" (/tasks), while @Get(':id') adds a route parameter to get /tasks/:id. The same :id sub-path shows up on @Patch, @Delete, and @Put because they all act on a single task by its id — that's the standard REST shape, and Nest just made it readable.
⚠️ Two methods that resolve to the same verb and the same path will collide — Nest matches in declaration order, so the first one wins and the second silently never runs. If a route "isn't being hit," check for a duplicate (and remember
@Get(':id')will happily match/tasks/anything, so order your literal routes before your:idroute when paths could overlap).
Parameter decorators: pulling the request apart
A route method usually needs something from the request — which task id, what query filter, what body to save. Parameter decorators inject exactly those pieces:
@Param('id') id: string— a route parameter from the path (:id).@Query('done') done: string— a single query-string value (?done=true).@Body() body: CreateTaskDto— the parsed request body.@Headers('authorization') auth: string— a request header.
import { Controller, Get, Post, Param, Query, Body } from '@nestjs/common';
import { CreateTaskDto } from './dto/create-task.dto';
@Controller('tasks')
export class TasksController {
@Get()
findAll(@Query('done') done?: string) {
// GET /tasks?done=true → done === 'true'
return `all tasks, filtered by done=${done}`;
}
@Get(':id')
findOne(@Param('id') id: string) {
// GET /tasks/42 → id === '42' (a string!)
return `one task with id ${id}`;
}
@Post()
create(@Body() body: CreateTaskDto) {
// POST /tasks with a JSON body
return body;
}
}
What just happened: each decorated argument grabbed one slice of the incoming request. @Query('done') read ?done=..., @Param('id') read the :id segment, and @Body() (with no argument) handed over the whole parsed JSON body. Notice findOne returns the value straight away — no response object in sight — and create echoes the body back. We typed the body as CreateTaskDto; that's a class describing the expected shape, which we'll define and validate properly in Phase 5. For now it's just a type annotation.
⚠️ Route params and query values arrive as strings — always.
@Param('id') id: stringgives you'42', not42. If you need a number, you parse it (or, better, let aParseIntPipedo it for you — that's Phase 5). Forgetting this is a classic first-week bug:id === 42isfalsewhenidis'42'.
There are two more decorators you'll see in other people's code: @Req() and @Res(), which hand you the raw Express request and response objects.
💡 Reach for
@Req()/@Res()rarely, and@Res()almost never. The moment you grab the rawresand callres.json()yourself, you opt out of Nest's response handling — interceptors and some features stop applying to that route. The whole point of the parameter decorators is that you don't need the raw request. Ask for the pieces you want and let Nest manage the rest.
Responses: just return a value
This is the part that surprises people coming from Express. In Nest you don't call res.send() — you return a value, and Nest serializes it. Return an object or array and Nest sends it as JSON; return a string and it sends text. The status code defaults to 200, except @Post() which defaults to 201 Created (the correct status for "I made a thing").
import { Controller, Get, Post, Delete, Param, Body, HttpCode, Header } from '@nestjs/common';
@Controller('tasks')
export class TasksController {
@Get(':id')
findOne(@Param('id') id: string) {
return { id, title: 'Write the docs', done: false }; // → 200, JSON
}
@Post()
create(@Body() body: { title: string }) {
return { id: '1', title: body.title, done: false }; // → 201, JSON
}
@Delete(':id')
@HttpCode(204) // override: "deleted, no content to return"
@Header('X-Deleted', 'true') // set a custom response header
remove(@Param('id') id: string) {
return; // nothing to send back
}
}
What just happened: findOne returned a plain object and Nest turned it into a 200 JSON response — no serialization code from us. create returned an object too, but because it's a @Post() the default status was 201. On remove we overrode the default with @HttpCode(204) (the standard "success, empty body" status for deletes) and tacked on a custom header with @Header(...). We never touched a response object once.
📝 Returning a value is the idiomatic Nest style, and it's why controllers read so cleanly — a method's signature tells you what it takes in, and its
returntells you what goes out. You'll go a long way before you ever need the rawres.
It's Express underneath
None of this is a parallel universe. When your app boots, Nest walks your controllers, reads the decorators, and registers each method as a route on Express (or Fastify, if you choose that adapter). @Get(':id') on @Controller('tasks') becomes, more or less, the app.get('/tasks/:id', ...) you'd have written by hand in Express. The decorators are a declarative layer over the same routing — Nest does the wiring so your code stays a clean description of intent.
That's the whole controller story: a class marks a base path, methods mark verbs and sub-paths, parameter decorators inject request pieces, and a returned value becomes the response. The method bodies have been stubs because the logic doesn't belong here — it belongs in a provider. Wiring those in is exactly where Phase 3 goes.
Recap
- A controller is a class whose decorated methods are routes;
@Controller('tasks')sets the base path and@Get/@Post/@Put/@Patch/@Deleteset the verb plus an optional sub-path. - Parameter decorators inject request pieces:
@Param('id')(route param),@Query('q')(query string),@Body()(parsed body),@Headers()(a header). - Route params and query values are always strings — parse them yourself or use a pipe (Phase 5).
- Return a value and Nest serializes it to JSON with a
200(or201for@Post); override with@HttpCode()and set headers with@Header(). @Req()/@Res()exist for raw Express access, but avoid them — grabbing rawresopts you out of Nest's response handling.- Under the hood it all compiles down to Express routes; controllers are a declarative layer over routing you already understand.
Quick check
[
{
"q": "Given @Controller('tasks') with a method decorated @Get(':id'), which request does it handle?",
"choices": ["POST /tasks", "GET /tasks", "GET /tasks/42", "GET /id"],
"answer": 2,
"explain": "The base path 'tasks' plus the sub-path ':id' makes GET /tasks/:id, so GET /tasks/42 matches with id = '42'."
},
{
"q": "A method handles @Get(':id') with @Param('id') id. For GET /tasks/42, what is the value and type of id?",
"choices": ["42 as a number", "'42' as a string", "{ id: 42 } as an object", "undefined"],
"answer": 1,
"explain": "Route params (and query values) always arrive as strings — you get '42', not 42. Parse it or use a pipe if you need a number."
},
{
"q": "A @Post() method does `return { id: '1', title: 'x', done: false };` and nothing else. What does the client receive?",
"choices": ["A 200 response with an empty body", "A 201 response with that object as JSON", "A 500 error because no response was sent", "A 204 response with no content"],
"answer": 1,
"explain": "Returning a value lets Nest auto-serialize it to JSON, and @Post defaults to 201 Created — no response object needed."
}
]
← Phase 1: What NestJS Is & Your First App · Guide overview · Phase 3: Providers & Dependency Injection →
Check your understanding
1. Given @Controller('tasks') with a method decorated @Get(':id'), which request does it handle?
2. A method handles @Get(':id') with @Param('id') id. For GET /tasks/42, what is the value and type of id?
3. A @Post() method does `return { id: '1', title: 'x', done: false };` and nothing else. What does the client receive?