Backend with NestJS
Backend with NestJS

Controllers & routing: order, status codes, @Res() and versions

Lesson · ~30 min
After this unit you can
  • Predict which handler a request reaches when :id is declared before a literal path, and fix it by ordering or with Nest 12's routeConflictPolicy / routeResolutionStrategy
  • Predict the default status for a handler (200, or 201 for POST) and change it with @HttpCode, @Header, @Redirect or an exception
  • Explain what @Res() makes your job (sending the response) and what it throws away (interceptor output, serialization), and use passthrough: true instead
  • Version routes with enableVersioning, controller and @Version metadata, VERSION_NEUTRAL and defaultVersion, and predict which requests 404

Read

Simple
1 / 2

The first route that matches wins

Flag

At 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.

Medium
1 / 2

@Res() hands you the response — and the job of sending it

Flag

Inject @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's map executes and its result goes nowhere; ClassSerializerInterceptor never sees your object, because res.json() serialized it first. The caching docs add that CacheInterceptor can't cache a route that injects @Res().
  • Exception filters still work. Throw NotFoundException and the client gets the usual 404 JSON — errors don't depend on the return value.
  • The handler is now Express code. res.json/res.cookie are Express's API (Fastify's reply differs), and a unit test needs a fake res.

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.

Complex
1 / 2

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/:id before /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?

Flag

@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?

Flag

A @Post("login") handler returns a token, and a @Delete(":id") handler returns nothing. With no other decorators, what statuses do clients get?

Flag

PUT /orders/:id should answer 201 when it created the order and 200 when it replaced one. How?

Flag

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?

Flag

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?

Flag

@Controller({ path: "cats", version: "1" }) contains @Version("2") @Get(":id") findOne(). URI versioning is on. Which requests reach findOne?

Flag

CatsV1Controller and CatsV2Controller both use path: "cats", with version: "1" and version: "2". main.ts never calls enableVersioning(). What does GET /cats do?

Flag

Fill in the blank

Declare these so GET /users/me reaches findMe on the default Express adapter, without any Nest 12 options.

Flag
@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.

Flag
app.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.

Flag
NestFactory.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
Flag
@Get("summary")
summary(@Res() res: Response) {
  const summary = this.reports.summary();
  return summary;          // used to be res.json(summary)
}
Flag
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)
Flag

Further reading