Controllers & Routing
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against NestJS 12.1 (npm 12.1.2), TypeScript 6 (6.0.3), Node.js 20.19+ or 22.12+, Express 5 (5.2.1) and Fastify 5 (5.12.5), and was checked on 2026-10-02. The examples use the Nest 12 CLI project layout (ESM, Vitest, with requests sent through supertest, fetch and Fastify’s inject()), and the outputs shown are what the application returns. The running scenario is the Bookshelf application used throughout this section.
A controller is the class that receives incoming HTTP requests and returns responses. It owns no routing code itself: decorators attach metadata (a path, an HTTP method, parameter sources) and Nest reads that metadata at bootstrap to register routes on the underlying HTTP adapter (Express or Fastify). Everything else a request touches before and after the handler (middleware, guards, interceptors, pipes, filters) is covered on its own page; the order is summarised in Request Lifecycle.
Controllers and the route map
A controller is a class decorated with @Controller() and registered in the controllers array of a module (see Modules). A class that is not listed there is never instantiated and none of its routes exist.
import { Body, Controller, Get, Post } from '@nestjs/common';
import { CreateBookDto } from './dto.js';
@Controller('books')
export class BooksController {
private readonly books = new Map<number, Book>();
private nextId = 1;
@Post()
create(@Body() dto: CreateBookDto): Book {
const book = { id: this.nextId++, ...dto };
this.books.set(book.id, book);
return book; // 201 Created: the default for POST
}
@Get()
findAll(): Book[] {
return [...this.books.values()];
}
}
The route of a handler is the controller prefix joined with the path given to the method decorator: @Controller('books') plus @Get('latest') maps GET /books/latest. The method name is irrelevant to Nest. You can generate a controller with nest g controller books or a full CRUD resource (controller, service, DTOs and a module) with nest g resource books; see Getting Started and the CLI.
Source: Controllers, "Routing".
@Controller() options
@Controller() has three call signatures: no argument, a prefix (string or string[]) or an options object.
| Option | Meaning |
|---|---|
|
The route prefix, a |
|
A |
|
A version (or array of versions) for the whole controller when versioning is enabled. Versioning is a separate feature, see the Versioning documentation. |
|
The injection scope of the controller ( |
|
Marks a request-scoped controller as durable (shared across requests of the same tenant). Meaningful only together with a request scope. |
Source: Controllers, "State sharing" and the ControllerOptions interface in @nestjs/common (checked in the installed package types).
An array of prefixes produces one route per prefix:
@Controller(['library', 'shelf'])
export class ExtrasController {
@Get('page')
page() { /* ... */ }
}
This answers both GET /library/page and GET /shelf/page.
Source: Controllers, "Routing" (the array form is accepted by @Controller() as well).
The global prefix
app.setGlobalPrefix('api/v1') prepends a prefix to every route of the application; exclude takes route strings (any method) or { path, method } objects.
import { RequestMethod } from '@nestjs/common';
app.setGlobalPrefix('api/v1', {
exclude: [{ path: 'shelf/page', method: RequestMethod.GET }],
});
With this setting GET /api/v1/library/page and GET /shelf/page answered 200, while GET /library/page answered 404.
Source: Global prefix.
HTTP method decorators
Nest provides a decorator for each HTTP method. All take an optional path, or an array of paths to serve the same handler on several routes.
| Decorator | HTTP method |
|---|---|
|
The standard methods of the same name. |
|
Every method. |
|
|
|
The |
|
WebDAV methods. They exist in |
@Controller('routes')
export class RoutesController {
// Several paths for one handler
@Get(['ping', 'health-check'])
multiPath() {
return 'pong';
}
@Search('books')
search(@Query('q') q?: string) {
return { method: 'SEARCH', q };
}
@QueryMethod('books')
queryMethod(@Query('q') q?: string) {
return { method: 'QUERY', q };
}
@All('echo')
echo(@Req() req: Request) {
return { method: req.method };
}
}
Calling these routes from a Node.js script:
const r = await fetch(`${base}/routes/books?q=x`, { method: 'QUERY' });
{ "method": "QUERY", "q": "x" }
SEARCH returned {"method":"SEARCH","q":"x"}, and PUT, PATCH and GET on /routes/echo each returned their own method name. On Express a QUERY request with a JSON body was handled without extra configuration. On Fastify, the same request needs a Content-Type header: a bare QUERY was answered with 400 and the message Method 'QUERY' must provide a 'Content-Type' header., while one with content-type: application/json returned 200 and the parsed body.
Source: Controllers, "Resources"; release notes v11.2.0.
Route paths and Express 5 syntax
Nest builds routes with the adapter’s own matcher. Express 5 uses path-to-regexp v8, which made the syntax stricter: a wildcard must have a name, optional parts use braces, and the old ?, + and regular-expression characters in strings are gone. Nest adds a compatibility layer so that the common unnamed trailing * still works, and the official docs describe the rules this way:
-
An asterisk at the end of a string path is a wildcard. In string paths the hyphen (
-) and the dot (.) are literal. -
In plain Express 5 a wildcard must be named, such as
abcd/{*splat};splatis an arbitrary name with no special meaning. Nest still accepts an unnamed*. -
An asterisk in the middle of a path requires a named wildcard on Express (for example
ab{*splat}cd), and Fastify does not support it at all.
| Syntax | Meaning (path-to-regexp v8) | Result on Express |
|---|---|---|
|
Legacy unnamed trailing wildcard, rewritten by Nest. |
Matched |
|
Named wildcard: one or more segments, captured as an array. |
|
|
Braces make a part optional; so the wildcard now matches zero or more segments. |
|
|
An optional parameter segment. |
|
// Unnamed trailing wildcard: Nest's compatibility layer rewrites it
@Get('abcd/*')
legacyWildcard(@Req() req: Request) {
return { url: req.url };
}
// Named wildcard (path-to-regexp v8): one or more segments
@Get('files/*path')
namedWildcard(@Param('path') path: string[] | string) {
return { path };
}
// {/*rest}: zero or more segments
@Get('docs{/*rest}')
optionalWildcard(@Param('rest') rest?: string[]) {
return { rest: rest ?? [] };
}
// {...}: an optional segment
@Get('catalog{/:lang}/books')
optionalSegment(@Param('lang') lang?: string) {
return { lang: lang ?? 'default' };
}
Source: Controllers, "Route wildcards" and Migration guide.
Note the type of a wildcard parameter: it is captured as an array of segments, not as a slash-separated string.
Fastify differences
Fastify’s router (find-my-way) has its own, smaller syntax. With the default FastifyAdapter:
-
abcd/*(trailing asterisk) and:a/x/:b(parameters) worked. -
files/*path,docs{/*rest},ab*cdandab{*splat}cdmadeNestFactory.create()fail at registration withWildcard must be the last character in the route. -
catalog{/:lang}/bookswas accepted but did not match/catalog/booksor/catalog/en/books(both404), so the Express optional-segment syntax is not portable.
If an application must run on both adapters, restrict itself to literal segments, :param tokens and a single trailing *, or declare two explicit routes with an array of paths (@Get(['docs', 'docs/:page'])).
Source: Controllers, "Route wildcards"; Express migration notes at Migrating to Express 5.
Route conflicts and resolution order
Nest registers routes in declaration order. On an order-sensitive adapter, such as the default Express adapter, a parametric route declared first silently shadows a more specific one declared later, and the application boots without a warning:
@Controller('users')
export class UsersController {
@Get(':id')
findOne() {
return { handler: 'findOne' };
}
@Get('me') // shadowed by ':id' with the default 'declaration' strategy
findMe() {
return { handler: 'findMe' };
}
}
GET /users/me returned {"handler":"findOne"} on Express. Pipes such as ParseIntPipe cannot help: routing chooses the handler before any pipe runs. The classical fix is to declare static routes before parametric ones.
Nest 12 adds two opt-in NestApplicationOptions properties. Both default to the previous behaviour.
| Option | Values and effect |
|---|---|
|
An object with a severity per conflict kind, each |
|
|
const app = await NestFactory.create(AppModule, {
routeConflictPolicy: { duplicate: 'error', shadow: 'error' },
});
RouteConflictException: Conflicting HTTP routes detected:
- 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.
Adjust route declarations or relax the 'routeConflictPolicy' option passed to NestFactory.create() to allow the application to start.
With routeResolutionStrategy: 'specificity' the same controller answered GET /users/me with {"handler":"findMe"}:
const app = await NestFactory.create(AppModule, {
routeResolutionStrategy: 'specificity',
});
Express is order-sensitive; Fastify is not, because find-my-way already ranks routes by specificity (on Fastify GET /users/me returned findMe with the default settings, and shadow and 'specificity' are no-ops there). The duplicate policy works on both adapters. The RouteConflictPolicy, RouteConflictPolicyLevel and RouteResolutionStrategy types are exported from @nestjs/common. Use shadow: 'error' in CI or in tests to catch the mistake early, and keep 'specificity' for codebases where reordering handlers is impractical.
Request data: parameter decorators
Handlers receive request data through parameter decorators. Each one reads from the platform request object, so you rarely need @Req().
| Decorator | Value |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Request cookies or verified signed cookies (the HTTP cookies feature) |
|
|
|
|
@Body(), @Query(), @Param() and @RawBody() also accept an options object with schema and pipes properties, which attaches a Standard Schema (Zod, Valibot, ArkType) to the parameter. The decorator only stores the schema as metadata; validation happens when the StandardSchemaValidationPipe (or a custom pipe that reads metadata.schema) is registered, as in Pipes & Validation.
Source: Controllers, "Request object".
Route parameters, query, headers and the request object
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number): Book {
const book = this.books.get(id);
if (!book) {
throw new NotFoundException(`Book ${id} not found`);
}
return book;
}
@Get('debug/request')
inspect(
@Req() request: Request,
@Ip() ip: string,
@Headers('user-agent') userAgent: string,
@Headers() headers: Record<string, string>,
) {
return { method: request.method, ip, userAgent, hasHeaders: Object.keys(headers).length > 0 };
}
curl -i localhost:3000/books/abc
{ "message": "Validation failed (numeric string is expected)", "error": "Bad Request", "statusCode": 400 }
GET /books/9 for a missing book returned 404 with {"message":"Book 9 not found","error":"Not Found","statusCode":404}. Route parameters (like query values) arrive as strings. The number annotation on id converts nothing; ParseIntPipe does, and rejects abc with a 400. To use Request as a type with Express install @types/express and import it with import type.
Source: Controllers, "Route parameters".
Request payloads and DTOs
A DTO (data transfer object) describes the shape of data sent over the network. Declare DTOs as classes, not interfaces: classes survive compilation, so pipes such as ValidationPipe can read their metatype at runtime, while interfaces are erased (see TypeScript for NestJS).
export class CreateBookDto {
title: string;
isbn: string;
pages: number;
}
export class ListBooksQuery {
limit?: string;
offset?: string;
author?: string;
}
curl -i -X POST localhost:3000/books \
-H 'content-type: application/json' \
-d '{"title":"Dune","isbn":"978-0441172719","pages":412}'
{ "id": 1, "title": "Dune", "isbn": "978-0441172719", "pages": 412 }
The response status was 201 and a Cache-Control: no-store header (set with @Header(), below). A class DTO on its own validates nothing: add a ValidationPipe with class-validator decorators, or a Standard Schema, to enforce the shape. With whitelist: true the ValidationPipe also strips properties not declared in the DTO.
Source: Controllers, "Request payloads".
Query parameters
@Query() returns the parsed query string, either as a whole object or one key at a time. Values are strings until a pipe converts them.
@Get('page')
page(
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
@Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number,
) {
return { limit, offset, types: [typeof limit, typeof offset] };
}
GET /library/page returned {"limit":10,"offset":0,"types":["number","number"]}, and ?limit=x returned the same 400 ParseIntPipe error as above.
A schema can replace the pipes. With StandardSchemaValidationPipe registered through app.useGlobalPipes(new StandardSchemaValidationPipe()) and Zod 4:
import { z } from 'zod';
const createBookSchema = z.object({
title: z.string().min(1),
pages: z.number().int().positive(),
});
type CreateBook = z.infer<typeof createBookSchema>;
@Post('validated')
create(@Body({ schema: createBookSchema }) body: CreateBook) {
return body;
}
@Get('typed/:id')
typed(@Param('id', { schema: z.coerce.number().int().positive() }) id: number) {
return { id, type: typeof id };
}
A valid body returned 201 and the parsed value; {"title":"","pages":-1} returned 400 with "message":["title: Too small: expected string to have >=1 characters","pages: Too small: expected number to be >0"]; GET /library/typed/7 returned {"id":7,"type":"number"} and /typed/abc a 400.
The Express simple and extended query parsers
Express 5 changed the default query parser from extended to simple. Nested objects (filter[where][name]=John) and bracket arrays (item[]=1) are therefore not parsed unless you switch parser. Both parsers were tried on /qp?filter[where][name]=John&item[]=1&item[]=2&tag=a&tag=b:
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.set('query parser', 'extended');
| Parser | @Query() value |
|---|---|
|
|
|
|
Repeated keys (tag=a&tag=b) become an array in both. On Fastify there is no such switch; pass a querystringParser to the adapter, for example with qs (npm install qs):
import qs from 'qs';
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ querystringParser: (str) => qs.parse(str) }),
);
On Fastify this produced {"filter":{"where":{"name":"John"}},"item":["1","2"]}. Fastify 5.12 printed a deprecation warning (FSTDEP022) saying that router options such as querystringParser are to be passed in routerOptions of the Fastify instance; the Nest docs still show the form above, so check the adapter documentation when you upgrade.
Source: Controllers, "Query parameters".
Responses
Status codes
The default status is 200, and 201 for POST. @HttpCode() overrides it for a handler; for a status that depends on runtime data, throw an exception (see Exception Filters & Error Handling) or use library-specific mode.
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseIntPipe) id: number): void {
this.books.delete(id);
}
DELETE /books/1 answered 204 with an empty body. A real delete endpoint also needs authorisation (see Guards); this example omits it to stay focused on routing.
Source: Controllers, "Status code".
Response headers
@Header(name, value) sets a static response header; use res.header() in library-specific mode for dynamic ones.
@Post()
@Header('Cache-Control', 'no-store')
create(@Body() dto: CreateBookDto): Book { /* ... */ }
Source: Controllers, "Response headers".
Redirects
@Redirect(url?, statusCode?) redirects the response; the status defaults to 302. A handler may return an object that follows the HttpRedirectResponse interface (url and optionally statusCode) to override the decorator values at runtime.
@Controller('redirects')
export class RedirectController {
@Get('static')
@Redirect('https://nestjs.com', 301)
staticRedirect() {}
@Get('docs')
@Redirect('https://docs.nestjs.com', 302)
getDocs(@Query('version') version?: string) {
if (version === '5') {
return { url: 'https://docs.nestjs.com/v5/' };
}
}
@Get('moved')
@Redirect()
moved() {
return { url: '/books', statusCode: 308 };
}
}
The answers are 301 to https://nestjs.com, 302 to https://docs.nestjs.com (and to …/v5/ with ?version=5) and 308 to /books. Never build the redirect target from unvalidated user input (an open redirect); validate it against an allow-list first.
Source: Controllers, "Redirection".
Async handlers and Observables
A handler may be an async function (the returned promise is awaited) or return an RxJS Observable; Nest subscribes and sends the last value emitted when the stream completes.
@Get('count')
async count(): Promise<{ count: number }> {
return { count: this.books.size };
}
@Get('latest')
latest(): Observable<Book | undefined> {
const all = [...this.books.values()];
return of(all[all.length - 1]);
}
Both returned the expected JSON. Streams that must keep sending events use Server-Sent Events (@Sse()), which is a different mechanism: a handler that returns an Observable normally responds once. Interceptors can also run RxJS operators on this stream (see Interceptors).
Source: Controllers, "Asynchronicity".
Sub-domain routing
The host option of @Controller() restricts a controller to requests whose Host header matches. Tokens such as :account capture the dynamic part, readable with @HostParam().
@Controller({ host: 'admin.example.com' })
export class AdminController {
@Get()
index(): string {
return 'Admin page';
}
}
@Controller({ host: ':account.example.com' })
export class AccountController {
@Get()
getInfo(@HostParam('account') account: string) {
return { account };
}
}
curl localhost:3000/ -H 'Host: acme.example.com'
{ "account": "acme" }
Host: admin.example.com answered Admin page, acme.example.com answered the account object, and example.com answered 404. The controllers are matched in registration order, and :account.example.com also matches admin.example.com, so declare the specific host first in the module. The official docs warn that Fastify does not support nested routers and tell you to use the Express adapter if you rely on sub-domain routing; in practice a host controller with @HostParam() does answer on Fastify 5.12 (inject() with a host header), but the officially supported path remains Express, so test your own setup before depending on it.
Source: Controllers, "Sub-domain routing".
Library-specific responses with @Res()
The standard approach is to return a value and let Nest send it: objects and arrays become JSON, primitives are sent as they are. The alternative is to inject the platform response with @Res() (alias @Response()) and call its methods. Nest detects @Res() or @Next() in the signature and switches the handler to library-specific mode, in which you are responsible for sending the response; if you do not, the request hangs.
@Controller('response')
export class ResponseController {
// Library-specific mode: Nest hands over the response completely
@Post('manual')
manual(@Res() res: Response) {
res.status(HttpStatus.CREATED).send({ mode: 'manual' });
}
// passthrough: set cookies/headers, still return the value
@Get('passthrough')
@HttpCode(202)
passthrough(@Res({ passthrough: true }) res: Response) {
res.cookie('seen', '1', { httpOnly: true, sameSite: 'lax' });
return { mode: 'passthrough' };
}
// Forgetting to send: the request hangs
@Get('hang')
hang(@Res() _res: Response) {
// intentionally nothing
}
}
POST /response/manual returns 201 and the JSON body; GET /response/passthrough returns 202, the JSON body and a Set-Cookie: seen=1; Path=/; HttpOnly; SameSite=Lax header; GET /response/hang never answers (a client with a 500 ms timeout gives up).
What @Res() costs, measured with a controller-level interceptor that wraps every result in { data: … }:
| Handler | Response body |
|---|---|
standard: returns |
|
|
|
|
|
The official chapter says library-specific mode loses compatibility with features that depend on standard response handling, such as interceptors and the @HttpCode() / @Header() decorators. In 12.1.2 @HttpCode(202) and @Header('X-A', '1') were in fact still applied to a @Res() handler on both Express and Fastify (the status and header are set before the handler runs), but interceptors were not, so the guidance is: prefer returning values, and use @Res({ passthrough: true }) when you only need the response object to set a cookie or a header. Beyond interceptors, @Res() ties code to the platform (an Express Response is not a Fastify reply) and makes unit tests harder because the response must be mocked.
Where it sits in the request lifecycle
Routing is the first step. The adapter matches the method and path (and host) to a handler; middleware registered for the route runs next, then guards, interceptors (before the handler), pipes on the parameters (including the ones attached with @Param('id', ParseIntPipe)), the handler itself, interceptors (after) and finally exception filters if anything threw. Because the handler is selected first, a pipe can never fix a route conflict (see Route conflicts and resolution order). Controllers are singletons by default: state held in a field of the controller is shared by every request, and per-request state belongs in a request-scoped provider (see Providers & Dependency Injection). The complete order and the binding scopes are in Request Lifecycle.
Pitfalls
-
Not registering the controller. A controller missing from a module’s
controllersarray yields404for all its routes, with no error at boot. -
Parametric before static.
@Get(':id')above@Get('me')shadowsmeon Express; reorder, or enablerouteConflictPolicyandrouteResolutionStrategy. -
Expecting numbers from
@Param()and@Query(). They are strings; addParseIntPipe,DefaultValuePipeor a schema. -
Interfaces as DTOs. They are erased at compile time and cannot be validated by pipes; use classes or Standard Schemas.
-
Old wildcard syntax.
'*'in the middle of a path and string patterns with?,+or()were Express 4 features; on Express 5 use named wildcards and braces, and avoid them on Fastify. -
Nested query objects returning flat keys. Express 5’s default
simpleparser does not nesta[b]=1; switch toextended(Express) or aquerystringParser(Fastify). -
@Res()without sending. The request hangs; usepassthrough: trueor return the value. -
Business logic in the controller. Keep controllers thin: parse, delegate to a provider, return.
What changed recently
-
Nest 12.0:
routeConflictPolicyandrouteResolutionStrategy; theschemaoption on@Body(),@Query(),@Param()and@RawBody(); the built-inStandardSchemaValidationPipe. -
Nest 11.2.0:
@QueryMethod()for theQUERYmethod, and the@SseSignal()decorator for SSE handlers. -
Nest 11: Express 5 became the default platform, bringing path-to-regexp v8 syntax and the
simplequery parser default.
Source: Migration guide; v12.0.0 and v11.2.0 release notes.
Where the book is outdated
The reference book (January 2025, see the section bibliography) predates Nest 12 and the later 11.x releases, so it does not cover the route-conflict options, QUERY support or the schema option on parameter decorators. Its routing examples follow the Express 4 era; on Express 5 apply the path syntax and query-parser rules above. Where an example returns an error object with HTTP 200, use an HTTP exception or @HttpCode() instead so clients can rely on the status code.