🚨 ¡Nueva review! 🔇 Los mejores cascos con ANC del mercado: los Sony WH-1000XM4 . ¡Échale un ojo! 👀

Controllers y Routing en NestJS

Serie NestJS #2 — Rutas, parámetros, body, DTOs y todo lo que entra por HTTP

Escrito por domin el 21 de marzo de 2026

Segundo post de la serie NestJS! Si no has visto el primero, pásate antes porque ahí montamos todo el entorno con Docker y explicamos la arquitectura base de NestJS. Hoy toca lo que más vas a usar en tu día a día: los Controllers.

Un controller es la primera línea de tu API. Es lo que recibe las peticiones HTTP, extrae los datos que vienen en la URL, en el body, en los headers… y le pasa todo eso a los services para que hagan el trabajo. Vamos a ver todos los decoradores, todas las formas de recibir datos y cómo organizar las rutas para que tu API quede limpia y profesional.

EA, vamos al lío.

Diagrama de rutas HTTP llegando a un controller de NestJS con decoradores.

1. ¿Qué es un Controller?

Ya lo vimos por encima en el post anterior, pero vamos a croquetar un poquito más. Un es una clase que agrupa rutas relacionadas. Si tienes una entidad users, tu UsersController tendrá las rutas para crear, leer, actualizar y borrar usuarios.

Un controller NO debería tener lógica de negocio. Su trabajo es recibir la petición, extraer los datos, llamar al service correspondiente y devolver la respuesta, e ya!

// src/users.controller.ts
import { Controller, Get } from '@nestjs/common';

@Controller('users')
export class UsersController {
    @Get()
    findAll(): string {
        return 'Aquí van todos los usuarios';
    }
}

El decorador @Controller('users') hace dos cosas:

  1. Marca la clase como un controller de NestJS.
  2. Establece el prefijo de ruta /users. Todas las rutas que definas dentro de este controller empezarán por /users.

Ojito cuidao que si estamos trasteando con el anterior código que hicimos en el post 1 de esta serie, vamos a tener que añadir este nuevo controller en el AppModule de la app, en controllers, si no, por mucho controller que hagas, si no lo añades, la app no lo procesará.

@Module({
  imports: [],
  controllers: [AppController, UsersController],
  providers: [AppService],
})
export class AppModule {}

Más o menos, asíN.


2. Decoradores de métodos HTTP

NestJS tiene un decorador para cada método HTTP, cada uno mapea un método de la clase a un tipo de petición concreta.

DecoradorMétodo HTTPUso típico
@Get()GETObtener recursos
@Post()POSTCrear un recurso nuevo
@Put()PUTReemplazar un recurso completo
@Patch()PATCHActualizar parcialmente un recurso
@Delete()DELETEEliminar un recurso
@Options()OPTIONSPreflight CORS
@Head()HEADComo GET pero sin body
@All()TODOSResponde a cualquier método

Los que vas a usar casi siempre @Get(), @Post(), @Patch() y @Delete(). Los demás están ahí por si los necesitas en casos muy concretos pero igual nunca los llegas a usar.

Combinando prefijo + ruta

La ruta final es la combinación del prefijo del controller con la ruta del decorador:

@Controller('users') // Prefijo: /users
export class UsersController {
    @Get() // GET /users
    findAll(): string {
        return 'Lista de usuarios';
    }

    @Get('active') // GET /users/active
    findActive(): string {
        return 'Usuarios activos';
    }

    @Post() // POST /users
    create(): string {
        return 'Crea un usuario';
    }
}

El nombre del método findAll, findActive, create le da absolutamente igual a NestJS. Podrías llamarlos papita() y sarvesita() y funcionaría igual, lo que importa es el decorador que lleva encima. A pesar de que el nombre sea en este sentido irrelevante, lo mejor es ponerles un nombre bien clarito para dejar documentado el código y entendible, así uno ya se imagina que hace, nombres descriptivos ahorran comentarios!


3. Parámetros de ruta con @Param()

Cuando necesitas identificar un recurso concreto, hay que usar parámetros dinámicos en la ruta. Se definen con : y se extraen con @Param(), amo a verlo:

// GET /users/42
@Get(':id')
findOne(@Param('id') id: string): string {
    return `Usuario con id ${id}`; // Usuario con id 42
}

El parámetro siempre llega como string. Si necesitas un number, puedes usar un Pipe de transformación que viene nativo con NestJS:

import { ParseIntPipe } from '@nestjs/common';

// GET /users/42 → id será de tipo number
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number): string {
    return `Usuario con id ${id}`;
}

Si alguien hace GET /users/abc, el ParseIntPipe devuelve automáticamente un error 400 Bad Request con un mensaje claro, no tienes que validar tú a mano el parámetro.

Múltiples parámetros

// GET /users/42/posts/7
@Get(':userId/posts/:postId')
findUserPost(
    @Param('userId', ParseIntPipe) userId: number,
    @Param('postId', ParseIntPipe) postId: number,
): string {
    return `Post ${postId} del usuario ${userId}`; // Post 42 del usuario 7
}

Objeto de parámetros completo

Si prefieres recibir todos los parámetros juntos como un objeto:

@Get(':id')
findOne(@Param() params: { id: string }): string {
    return `Usuario con id ${params.id}`;
}

Normalmente es más limpio extraer cada parámetro por separado con @Param('nombre'), pero la opción está ahí aunque no se recomienda usar.


4. Query strings con @Query()

Los query parameters son los que van después del ? en la URL, se extraen con @Query():

// GET /users?page=2&limit=10
@Get()
findAll(
    @Query('page') page: string,
    @Query('limit') limit: string,
): string {
    return `Página ${page}, límite ${limit}`;
}

Los query parameters también llegan como string. Para transformarlos a number, puedes usar pipes igual que con @Param:

// GET /users?page=2&limit=10
@Get()
findAll(
    @Query('page', ParseIntPipe) page: number,
    @Query('limit', ParseIntPipe) limit: number,
): string {
    return `Página ${page}, mostrando ${limit} resultados`;
}

Query object completo

Si tienes muchos query parameters, puedes tiparlos con una interfaz y recibirlos todos de golpe:

interface FindUsersQuery {
    page?: string;
    limit?: string;
    sort?: string;
    order?: 'asc' | 'desc';
}

@Get()
findAll(@Query() query: FindUsersQuery): string {
    return `Filtros: ${JSON.stringify(query)}`;
}

Veremos en el post de DTOs y Validación cómo validar estos query parameters con class-validator para que no te cuelen cualquier cosa.


5. Request body con @Body()

Para crear o actualizar recursos, los datos llegan en el body de la petición. Se extraen con @Body():

@Post()
create(@Body() body: { name: string; email: string }): string {
    return `Creado usuario ${body.name}`;
}

Pero oye, eso de tipar el body inline está feo y no escala, aquí es donde entran los DTOs.

DTOs: Data Transfer Objects

Un es una clase que define la forma de los datos que entran en la API. ¿Por qué una clase y no una interfaz? Porque las interfaces de TypeScript desaparecen después de compilar a JavaScript. No existen en runtime , las clases sí. Y NestJS necesita la información del tipo en runtime para funcionalidades como validación automática con ValidationPipe y los Pipes.

// dto/create-user.dto.ts
export class CreateUserDto {
    name: string;
    email: string;
    age: number;
}

Y en el controller:

import { type CreateUserDto } from './dto/create-user.dto';

@Post()
create(@Body() createUserDto: CreateUserDto): string {
    return `Creado usuario ${createUserDto.name} con email ${createUserDto.email}`;
}

Ahora el body está tipado tt y si intentas acceder a createUserDto.direccion, TypeScript te avisa de que esa propiedad no existe. Y más adelante, cuando añadamos class-validator, este DTO también servirá para validar automáticamente que los datos que llegan tienen la forma correcta.

Extraer un campo concreto del body

Si solo necesitas un campo:

@Post()
create(@Body('name') name: string): string {
    return `Creado usuario ${name}`;
}

Aunque normalmente es mejor usar el DTO completo porque en un proyecto real vas a necesitar más de un campo.


6. Headers con @Headers()

Para leer cabeceras HTTP de la petición:

import { Headers } from '@nestjs/common';

// Leer un header concreto
@Get()
findAll(@Headers('authorization') auth: string): string {
    return `Token: ${auth}`;
}

// Leer todos los headers
@Get('debug')
debug(@Headers() headers: Record<string, string>): Record<string, string> {
    return headers;
}

Esto es útil para tokens de autenticación, idioma, versión de la API, etc. Pero para auth no lo hagas así a mano, ya veremos en el post de Authentication cómo usar Guards y Passport para que sea automático.


7. Status codes con @HttpCode()

Por defecto, NestJS devuelve 200 para todo excepto POST que devuelve 201, si necesitas cambiarlo se hace así:

import { HttpCode, HttpStatus } from '@nestjs/common';

@Post()
@HttpCode(HttpStatus.NO_CONTENT) // 204
create(@Body() createUserDto: CreateUserDto): void {
    // No devolvemos nada, status 204
}

@Delete(':id')
@HttpCode(204)
remove(@Param('id', ParseIntPipe) id: number): void {
    // Recurso eliminado, 204 No Content
}

NestJS tiene el enum HttpStatus con todos los códigos HTTP para que no tengas que recordar los números de memoria, que está de pm. Puedes usar HttpStatus.OK (200), HttpStatus.CREATED (201), HttpStatus.NO_CONTENT (204), HttpStatus.BAD_REQUEST (400), etc.


8. Headers de respuesta con @Header()

Para añadir cabeceras a la respuesta (no confundir con @Headers() que lee las de la petición):

import { Header } from '@nestjs/common';

@Post()
@Header('Cache-Control', 'no-store')
@Header('X-Custom-Header', 'mi-valor')
create(@Body() createUserDto: CreateUserDto): CreateUserDto {
    return createUserDto;
}

El decorador @Header() (singular, sin ‘s’) establece una cabecera en la respuesta. Es útil para cache control, cabeceras personalizadas, CORS manual, etc.


9. Redirects con @Redirect()

Si necesitas redirigir a otra URL, lo puedes hacer con el decorator @Redirect así:

import { Redirect } from '@nestjs/common';

@Get('docs')
@Redirect('https://docs.nestjs.com', 302)
redirectToDocs(): void {}

Y si necesitas un redirect dinámico dependiendo de alguna condición, devuelves un objeto con url y statusCode:

@Get('docs')
@Redirect('https://docs.nestjs.com', 302)
redirectToDocs(@Query('version') version: string) {
    if (version === '9') {
        return { url: 'https://docs.nestjs.com/v9/', statusCode: 301 };
    }
    // Si no devuelves nada, usa el redirect por defecto del decorador
}

10. Tabla de decoradores de parámetros

Esta tabla es una referencia de los parámetros de Express a NestJS. Guárdala porque igual la consultas alguna vez:

Decorador NestJSEquivalente en Express
@Req() / @Request()req
@Res() / @Response()res
@Param(key?)req.params / req.params[key]
@Body(key?)req.body / req.body[key]
@Query(key?)req.query / req.query[key]
@Headers(name?)req.headers / req.headers[name]
@Ip()req.ip
@Session()req.session
@HostParam()req.hosts

Si vienes de Express, esta tabla es útil. Cada req.body, req.params, req.query que hacías en Express ahora tiene su propio decorador que extrae exactamente lo que necesitas de forma limpia y tipada.


11. El objeto Response: dos formas de responder

NestJS te da dos formas de enviar respuestas. La primera es la estándar y la que deberías usar siempre que puedas:

Forma estándar (la buena)

Simplemente devuelves un valor desde el método:

@Get()
findAll(): User[] {
    return this.usersService.findAll();
    // NestJS serializa a JSON automáticamente, status 200
}

@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number): User {
    return this.usersService.findOne(id);
    // Objeto → JSON automático
}

@Post()
create(@Body() dto: CreateUserDto): User {
    return this.usersService.create(dto);
    // POST → status 201 por defecto
}

Forma library-specific (la que hay que evitar)

Inyectando el objeto Response de Express directamente:

import { Res } from '@nestjs/common';
import { Response } from 'express';

@Get()
findAll(@Res() res: Response): void {
    res.status(200).json(this.usersService.findAll());
}
Úsalo cuando...
  • La forma estándar (devolver valores). Es más limpia, más testeable y funciona con Interceptors y serialización automática
  • @Res({ passthrough: true }) si necesitas acceso al response para cookies o headers pero quieres que NestJS siga manejando la respuesta
Evítalo cuando...
  • @Res() sin passthrough. Te obliga a manejar la respuesta manualmente, pierdes interceptors, serialización y NestJS se desentiende
  • Mezclar @Res() con return. Si usas @Res(), NestJS no procesa el valor de retorno y la petición se queda colgada

12. Handlers asíncronos

En el día a día, probablemente tus controllers van a llamar a services que hacen consultas a base de datos, peticiones HTTP, etc, todo asíncrono. NestJS lo maneja de forma nativa:

Con async/await (lo más común)

@Get()
async findAll(): Promise<User[]> {
    return this.usersService.findAll();
    // NestJS resuelve la Promise y envía el resultado
}

@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number): Promise<User> {
    return this.usersService.findOne(id);
}

Con Observables (RxJS)

import { Observable } from 'rxjs';

@Get()
findAll(): Observable<User[]> {
    return this.usersService.findAll();
    // NestJS se suscribe y envía el último valor emitido
}

Vale me parece genial nene, pero ¿qué es un Observable?

Si vienes de async/await y ves Observable por primera vez, es normal quedarse un poco loco, vamos a explicarlo paque se entienda.

Una Promise es como pedir una pizza por teléfono. Llamas, haces el pedido, y en algún momento te llega una pizza, una sola y se acabó. Eso es una Promise: un valor futuro que llega una vez.

// Promise: una pizza, un valor, se acabó
const pizza: Promise<Pizza> = pedirPizza();
const miPizza = await pizza; // Llega UNA pizza

Un Observable es como suscribirte a un canal de YouTube. Te suscribes y te van llegando vídeos cuando el canal los sube. Pueden llegar 0, 1, 5, 100 o infinitos vídeos a lo largo del tiempo y puedes desuscribirte cuando quieras.

// Observable: un canal de YouTube, múltiples valores a lo largo del tiempo
import { Observable } from 'rxjs';

const canal: Observable<Video> = suscribirseAlCanal();
canal.subscribe({
    next: (video) => console.log('¡Nuevo vídeo!', video),    // Cada vez que sube uno
    error: (error) => console.log('El canal petó', error),    // Si el canal explota
    complete: () => console.log('El canal cerró'),             // Si el canal se cierra
});
ConceptoPromiseObservable
AnalogíaPedir una pizzaSuscribirte a un canal
¿Cuántos valores?Uno solo0, 1, 100 o infinitos
¿Se puede cancelar?No (una vez pedida, viene)Sí (te desuscribes)
¿Cuándo se ejecuta?Inmediatamente al crearlaSolo cuando alguien se suscribe ( lazy )
Sintaxisawait promiseobservable.subscribe()

¿Y por qué NestJS usa Observables? Porque internamente su pipeline (interceptors, guards, filters) está construido con RxJS. Cuando tu handler devuelve una Promise, NestJS la convierte a Observable por debajo. Cuando devuelves un Observable directamente, NestJS se suscribe automáticamente, coge el valor y lo manda como respuesta HTTP.

¿Cuándo vas a usar Observables en vez de Promises? En la práctica, casi nunca en los controllers. El async/await es más limpio y más fácil de leer. Los Observables brillan en los Interceptors y cuando trabajas con WebSockets o eventos en tiempo real. En los controllers, usa async/await y no te compliques.

Las dos formas son bien eh, en la práctica, async/await es lo que usa el 95% del mundo porque es más fácil de leer. Los Observables tienen sentido cuando trabajas con streams o eventos reactivos.


13. Montando un CRUD real: el módulo Users

Vamos a montar algo real dentro de nuestro proyecto Docker del post anterior, vamos a crear un módulo users completo.

13.1. Generar con el CLI

Generar módulo Users 0 / 1
~$
Pulsa para ejecutar el siguiente comando

nest g resource te genera toda la estructura del CRUD y en la última línea puedes ver que actualiza el AppModule automáticamente para importar el UsersModule, eso es lo bonito del CLI.

13.2. La entidad User

Empezamos definiendo cómo es un usuario. Editamos el archivo entities/user.entity.ts:

// src/users/entities/user.entity.ts
export class User {
    id: number;
    name: string;
    email: string;
    age: number;
    active: boolean;
}

De momento es una clase simple, cuando lleguemos al post de TypeORM le pondremos los decoradores de base de datos.

13.3. Los DTOs

El DTO de creación define qué datos necesitas para crear un usuario:

// src/users/dto/create-user.dto.ts
export class CreateUserDto {
    name: string;
    email: string;
    age: number;
}

Y el de actualización usa PartialType para hacer todos los campos opcionales, esto viene del paquete @nestjs/mapped-types:

// src/users/dto/update-user.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';

export class UpdateUserDto extends PartialType(CreateUserDto) {}

es genial porque no tienes que repetir los campos. Si mañana añades un campo phone al CreateUserDto, automáticamente estará disponible (y será opcional) en UpdateUserDto.

13.4. El Service

El service se encarga de la lógica. De momento simulamos una base de datos con un array en memoria:

// src/users/users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { type CreateUserDto } from './dto/create-user.dto';
import { type UpdateUserDto } from './dto/update-user.dto';
import { User } from './entities/user.entity';

@Injectable()
export class UsersService {
    private users: User[] = [];
    private idCounter: number = 1;

    create(createUserDto: CreateUserDto): User {
        const user: User = {
            id: this.idCounter++,
            ...createUserDto,
            active: true,
        };
        this.users.push(user);
        return user;
    }

    findAll(): User[] {
        return this.users;
    }

    findOne(id: number): User {
        const user = this.users.find((u) => u.id === id);
        if (!user) {
            throw new NotFoundException(`Usuario con id ${id} no encontrado`);
        }
        return user;
    }

    update(id: number, updateUserDto: UpdateUserDto): User {
        const user = this.findOne(id);
        Object.assign(user, updateUserDto);
        return user;
    }

    remove(id: number): void {
        const index = this.users.findIndex((u) => u.id === id);
        if (index === -1) {
            throw new NotFoundException(`Usuario con id ${id} no encontrado`);
        }
        this.users.splice(index, 1);
    }
}

Mira el NotFoundException. Es una excepción que viene con NestJS de forma nativa y automáticamente devuelve un 404 con un mensaje JSON limpio:

{
    "statusCode": 404,
    "message": "Usuario con id 99 no encontrado",
    "error": "Not Found"
}

No tienes que montar tu propio sistema de errores porque NestJS tiene excepciones para cada status code: BadRequestException (400), UnauthorizedException (401), ForbiddenException (403), ConflictException (409)… Ya veremos esto más en profundidad en el post de Exception Filters.

13.5. El Controller completo

Ahora sí, el controller que junta todo:

// src/users/users.controller.ts
import {
    Controller,
    Get,
    Post,
    Body,
    Patch,
    Param,
    Delete,
    ParseIntPipe,
    HttpCode,
    HttpStatus,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { type CreateUserDto } from './dto/create-user.dto';
import { type UpdateUserDto } from './dto/update-user.dto';
import { type User } from './entities/user.entity';

@Controller('users')
export class UsersController {
    constructor(private readonly usersService: UsersService) {}

    @Post()
    create(@Body() createUserDto: CreateUserDto): User {
        return this.usersService.create(createUserDto);
    }

    @Get()
    findAll(): User[] {
        return this.usersService.findAll();
    }

    @Get(':id')
    findOne(@Param('id', ParseIntPipe) id: number): User {
        return this.usersService.findOne(id);
    }

    @Patch(':id')
    update(
        @Param('id', ParseIntPipe) id: number,
        @Body() updateUserDto: UpdateUserDto,
    ): User {
        return this.usersService.update(id, updateUserDto);
    }

    @Delete(':id')
    @HttpCode(HttpStatus.NO_CONTENT)
    remove(@Param('id', ParseIntPipe) id: number): void {
        this.usersService.remove(id);
    }
}

Cada método hace exactamente una cosa:

Todos los id se transforman a number con ParseIntPipe. Todos los tipos de retorno están bien definidos y no hay un solo any en todo el código.

13.6. El Module

El módulo conecta el controller con el service:

// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';

@Module({
    controllers: [UsersController],
    providers: [UsersService],
})
export class UsersModule {}

Y el AppModule importa este módulo (el CLI ya lo hizo por nosotros):

// src/app.module.ts
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';

@Module({
    imports: [UsersModule],
})
export class AppModule {}

13.7. Probando el CRUD

Con el contenedor corriendo, probamos todos los endpoints:

Probar CRUD de Users 0 / 5
~$
Pulsa para ejecutar el siguiente comando

CRUD completo y tipado, con manejo de errores y corriendo en Docker y no hemos escrito ni una línea de configuración de Express.


14. Rutas con wildcards

Para casos puntuales donde necesitas que una ruta capture cualquier path:

@Get('assets/*')
serveAssets(): string {
    // Captura: GET /users/assets/foto.jpg
    // Captura: GET /users/assets/docs/manual.pdf
    return 'Sirviendo assets';
}

El * captura cualquier cosa que venga después. No es algo que vayas a usar a diario, pero es bien saber que existe.


15. Recapitulando

🎯 @Controller()

Define el controller y su prefijo de ruta. Todas las rutas del controller empiezan por ese prefijo.

🛣️ Decoradores HTTP

@Get, @Post, @Put, @Patch, @Delete mapean métodos de la clase a endpoints HTTP concretos.

📦 @Param / @Query / @Body

Extraen datos de la URL, query string y body de forma tipada. Con Pipes se validan y transforman automáticamente.

📋 DTOs como clases

Clases (no interfaces) para definir la forma de los datos. Existen en runtime para validación automática.

🔄 ParseIntPipe

Transforma strings a numbers automáticamente y devuelve 400 si el valor no es válido.

✅ Respuesta estándar

Devuelve valores directamente. NestJS serializa a JSON, gestiona el status code y los headers.

En el próximo post vamos a meternos con los Providers y la Inyección de Dependencias, que es lo que hace que todo este tinglao funcione bien. Veremos custom providers, scopes, useFactory, useValue y cómo NestJS resuelve las dependencias.

EA, nos vemos en los bares!! 🍺


Pon a prueba lo aprendido

1. ¿Qué decorador establece el prefijo de ruta para todas las rutas de un controller?

2. ¿Qué status code devuelve NestJS por defecto en una petición POST?

3. ¿Por qué los DTOs en NestJS deben ser clases y no interfaces de TypeScript?

4. ¿Qué hace ParseIntPipe cuando recibe un valor que no es un número?

5. ¿Qué problema tiene usar @Res() sin passthrough en un handler?