Controllers & routing: order, status codes, @Res() and versions
- Predict which handler a request reaches when
:idis declared before a literal path, and fix it by ordering or with Nest 12'srouteConflictPolicy/routeResolutionStrategy - Predict the default status for a handler (200, or 201 for POST) and change it with
@HttpCode,@Header,@Redirector an exception - Explain what
@Res()makes your job (sending the response) and what it throws away (interceptor output, serialization), and usepassthrough: trueinstead - Version routes with
enableVersioning, controller and@Versionmetadata,VERSION_NEUTRALanddefaultVersion, and predict which requests 404
Read
The first route that matches wins
FlagAt startup Nest walks your controllers — in the order the module lists them, methods top to bottom — and registers one route per handler. On the default Express adapter, a request goes to the first registered route whose pattern matches. Not the most specific one: the first.
@Controller("users")
export class UsersController {
@Get(":id") // registered first
findOne(@Param("id") id: string) {
return this.users.findOne(id);
}
@Get("me") // never reached
findMe(@Req() req: Request) {
return this.users.findOne(req.user.id);
}
}
:id matches any single path segment, and me is a segment. So GET /users/me runs
findOne with id === "me", and findMe is dead code. The app boots without a warning; you
find out when a user gets "user not found" for their own profile.
The fix is ordering: literal paths above parametric ones.
@Get("me") findMe() {} // literal first
@Get(":id") findOne() {} // then the catch-all for one segment
A pipe can't rescue it
Routing chooses the handler before any pipe runs. Put ParseIntPipe on the parameter and the
request still lands in findOne — the pipe just rejects it:
@Get(":id")
findOne(@Param("id", ParseIntPipe) id: number) {}
@Get("recent")
recent() {}
// GET /orders/recent -> 400 "Validation failed (numeric string is expected)"
That 400 looks like a validation bug. It's a routing bug.
It crosses controllers too
Two controllers can share a prefix, and the module's controllers array decides which
registers first:
@Module({ controllers: [UsersController, ProfileController] })
// UsersController: @Get(":id") under "users"
// ProfileController: @Get("profile") under "users" -> GET /users/profile runs findOne("profile")
Nest 12 (the current major) can catch this for you, but only if you ask: the
routeConflictPolicy and routeResolutionStrategy options on NestFactory.create() are off by
default. The complex tier covers them.
@Res() hands you the response — and the job of sending it
FlagInject @Res() and Nest switches that handler into library-specific mode: you get Express's
own res object, and Nest stops sending the response for you.
@Controller("users")
@UseInterceptors(ClassSerializerInterceptor)
export class UsersController {
@Get("me")
me(@Res() res: Response) {
res.json(this.users.current()); // passwordHash goes out with it
}
}
User marks passwordHash with @Exclude(), and in standard mode the serializer strips it.
Here it leaks. What changed, checked on Nest 12.1 and 11.2 with Express:
- You must send. Nest no longer sends what you return. Write
return user;with@Res()and nothing ever answers — the client waits until its own timeout. - Interceptors still run, but their output is thrown away. A
{ data }envelope interceptor'smapexecutes and its result goes nowhere;ClassSerializerInterceptornever sees your object, becauseres.json()serialized it first. The caching docs add thatCacheInterceptorcan't cache a route that injects@Res(). - Exception filters still work. Throw
NotFoundExceptionand the client gets the usual 404 JSON — errors don't depend on the return value. - The handler is now Express code.
res.json/res.cookieare Express's API (Fastify's reply differs), and a unit test needs a fakeres.
passthrough: true — touch the response, keep the rest
Most @Res() use is "I need to set one cookie or header". passthrough: true gives you res
and leaves sending to Nest, so your return value still flows through interceptors and
serialization:
@Post("login")
@HttpCode(200)
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
const { token, user } = await this.auth.login(dto);
res.cookie("session", token, { httpOnly: true, secure: true, sameSite: "lax" });
return user; // serialized and sent by Nest, as usual
}
It's also the clean way to choose a success status at runtime:
@Put(":id")
async upsert(@Param("id") id: string, @Body() dto: OrderDto, @Res({ passthrough: true }) res: Response) {
const { order, created } = await this.orders.upsert(id, dto);
res.status(created ? 201 : 200);
return order;
}
Rule of thumb: reach for full @Res() only when you genuinely produce the bytes yourself — a
hand-rolled stream, a download with a library that writes to res. Everything else is a return
value, a decorator, an exception, or passthrough.
Making route conflicts loud (Nest 12)
Flag"Literal before parametric" is a convention, and conventions drift — one controller added to the
wrong end of a controllers array and /users/profile quietly runs findOne("profile").
Nest 12 adds two NestFactory.create() options that turn this into something a machine checks.
Both are off by default, so upgrading changes nothing until you set them, and Nest 11 has
neither.
routeConflictPolicy — report conflicts at boot
const app = await NestFactory.create(AppModule, {
routeConflictPolicy: { duplicate: "error", shadow: "error" },
});
Each kind takes "off" (default), "warn" or "error":
shadow— two patterns can match the same request, and the first registered wins:/users/:idbefore/users/me.duplicate— the same method, path, host and version registered twice.
With "warn" you get one log line per pair at startup:
Route GET /users/me (UsersController#findMe) is shadowed by GET /users/:id
(UsersController#findOne). The first-registered route will match all matching requests on
order-sensitive adapters.
With "error", every offending pair is collected into a single RouteConflictException,
thrown from app.init() (or app.listen()), so the app doesn't start and you see all the
conflicts at once. Set it in every environment and the e2e suite's boot is the check: a shadowed
route fails CI instead of production.
duplicate catches a quieter mistake too. Two controllers that carry version: "1" and
version: "2" on the same path are a duplicate when versioning was never enabled — the version
metadata is ignored, both register GET /cats, and v2 is dead. duplicate: "error" refuses to
boot; with enableVersioning() in place, the same two controllers pass.
routeResolutionStrategy — let Nest order them
NestFactory.create(AppModule, { routeResolutionStrategy: "specificity" });
"specificity" registers literal segments before parametric ones and parametric before
wildcards, whatever order you declared them in. /users/me then reaches findMe, and the shadow
policy stops reporting that pair. It still reports what sorting can't fix: :id declared above
:slug are equally specific, so :slug stays unreachable. The default, "declaration", keeps
the old first-declared behaviour.
Which to choose: specificity makes the router forgiving; the policy makes the codebase honest.
On an existing app, turn on shadow: "warn" first and read the list — any shadow that some
client relies on today is a behaviour change waiting to happen when you flip the strategy.
Quiz
UsersController declares @Get(":id") findOne() and, below it, @Get("me") findMe(). On the default Express adapter, what does GET /users/me run?
@Get(":id") findOne(@Param("id", ParseIntPipe) id: number) is declared above @Get("recent") recent(). GET /orders/recent returns 400 "Validation failed (numeric string is expected)". What's the fix?
A @Post("login") handler returns a token, and a @Delete(":id") handler returns nothing. With no other decorators, what statuses do clients get?
PUT /orders/:id should answer 201 when it created the order and 200 when it replaced one. How?
A controller uses ClassSerializerInterceptor, and User.passwordHash carries @Exclude(). One handler is rewritten as me(@Res() res: Response) { res.json(this.users.current()); }. What does the client receive?
The app calls enableVersioning({ type: VersioningType.URI }), and HealthController has no version, so the load balancer checks GET /health. Someone adds defaultVersion: "1". What happens to that check?
@Controller({ path: "cats", version: "1" }) contains @Version("2") @Get(":id") findOne(). URI versioning is on. Which requests reach findOne?
CatsV1Controller and CatsV2Controller both use path: "cats", with version: "1" and version: "2". main.ts never calls enableVersioning(). What does GET /cats do?
Fill in the blank
Declare these so GET /users/me reaches findMe on the default Express adapter, without any Nest 12 options.
@Controller("users") export class UsersController { @Get("") findMe() {} @Get("") findOne(@Param("id") id: string) {} }
Answer a successful delete with 204, mark a report as never cacheable, and send readers who ask for version 5 to the old docs instead of the decorator's URL.
Flag@Delete(":id") @(204) remove(@Param("id") id: string) {} @Get("report") @("Cache-Control", "no-store") report() {} @Get("docs") @Redirect("https://docs.example.com", 302) docs(@Query("version") version?: string) { if (version === "5") return { : "https://docs.example.com/v5/" }; }
Set a session cookie but leave sending to Nest, so interceptors and serialization still apply to the returned user.
Flag@Post("login") @HttpCode(200) async login(@Body() dto: LoginDto, @Res({ : true }) res: Response) { const { token, user } = await this.auth.login(dto); res.cookie("session", token, { httpOnly: true, secure: true }); return user; }
Put the version in the path, make this controller version 2, and keep the health check out of versioning.
Flagapp.enableVersioning({ type: VersioningType. }); @Controller({ path: "cats", : "2" }) export class CatsV2Controller { @Get() list() { return ["Tom"]; } } @Controller({ path: "health", version: }) export class HealthController { @Get() check() { return { ok: true }; } }
Nest 12: refuse to start when one route shadows another — or, instead, have Nest register literal segments before parametric ones whatever the declaration order.
FlagNestFactory.create(AppModule, { routeConflictPolicy: { shadow: "" } }); NestFactory.create(AppModule, { routeResolutionStrategy: "" });
Predict the output
@Controller("users")
export class UsersController {
@Get(":id")
findOne(@Param("id") id: string) { return { handler: "findOne", id }; }
}
@Controller("users")
export class ProfileController {
@Get("profile")
profile() { return { handler: "profile" }; }
}
@Module({ controllers: [UsersController, ProfileController] })
export class AppModule {}
// Nest 12 on Express, no routing options set. Request: GET /users/profile@Get("summary")
summary(@Res() res: Response) {
const summary = this.reports.summary();
return summary; // used to be res.json(summary)
}app.enableVersioning({
type: VersioningType.HEADER,
header: "X-API-Version",
defaultVersion: "1",
});
@Controller({ path: "cats", version: "1" })
export class CatsV1Controller {
@Get() list() { return ["Tom"]; }
}
// curl -i http://localhost:3000/cats (no X-API-Version header)