Controllers in NestJS: Handling HTTP Requests
Imagine you're building a restaurant. The kitchen (services) prepares the food, but you need waiters (controllers) to take orders from customers and deliver the meals. Controllers in NestJS work exactly like this - they're the interface between the outside world and your application's business logic.
Today, we'll discover how controllers handle everything that comes through HTTP - from simple GET requests to complex file uploads. By the end, you'll be building RESTful APIs with confidence.
Quick Reference
Basic controller:
@Controller("users")
export class UsersController {
@Get()
findAll() {
return ["user1", "user2"];
}
@Get(":id")
findOne(@Param("id") id: string) {
return { id };
}
@Post()
create(@Body() data: any) {
return data;
}
}
HTTP Methods:
@Get()- Retrieve data@Post()- Create new resource@Put()/@Patch()- Update resource@Delete()- Remove resource
Parameter Extraction:
@Param('id')- URL parameters@Query('search')- Query strings@Body()- Request body@Headers('authorization')- HTTP headers
Gotchas:
- ⚠️ Route order matters - specific routes before generic ones
- ⚠️ Always use
@Controller()decorator - ⚠️ Return values auto-serialize to JSON
What You Need to Know First
Required reading:
- NestJS: Enterprise Node.js Framework - Understanding NestJS fundamentals
- NestJS Project Setup - How to create and run projects
Technical prerequisites:
- HTTP basics: Understanding GET, POST, PUT, DELETE methods
- REST API concepts: What RESTful routing means
- TypeScript basics: Classes, decorators, and types
- JSON: How data is formatted and exchanged
What We'll Cover in This Article
By the end of this guide, you'll understand:
- What controllers do and why they're essential
- How to define routes using decorators
- All HTTP methods (@Get, @Post, @Put, @Patch, @Delete)
- How to extract data from requests (params, query, body, headers)
- Route wildcards and patterns
- Status codes and custom responses
- Request and response objects
- Async/await in controllers
What We'll Explain Along the Way
These concepts will be explained with examples:
- RESTful API design patterns
- Route parameter extraction mechanisms
- JSON serialization (automatic conversion)
- HTTP status codes and when to use them
- Express request/response objects (under the hood)
- Async operations in route handlers
What Are Controllers?
Controllers are the entry point for all HTTP requests in your NestJS application. They're like receptionists at a hotel - they greet visitors, understand what they need, and direct them to the right services.
Visual: How Controllers Fit in the Architecture
Let's see where controllers sit in the NestJS architecture:
Flow explanation:
- Client sends HTTP request (GET /users)
- Controller receives request and extracts data
- Service handles business logic (validation, calculations)
- Database stores/retrieves data
- Response flows back through the same path
The Controller's Role
// Think of a controller as a receptionist desk
@Controller("hotel")
export class HotelController {
constructor(private hotelService: HotelService) {}
// Guest asks: "Do you have any rooms available?"
@Get("rooms")
getAvailableRooms() {
// Controller delegates to service (front desk calls the manager)
return this.hotelService.findAvailableRooms();
}
// Guest says: "I'd like to book a room"
@Post("bookings")
createBooking(@Body() bookingData: CreateBookingDto) {
// Controller passes the request to service
return this.hotelService.createBooking(bookingData);
}
// Guest asks: "What's the status of my booking #123?"
@Get("bookings/:id")
getBooking(@Param("id") id: string) {
// Controller extracts ID and asks service
return this.hotelService.findBooking(id);
}
}
Key responsibilities:
- Receive HTTP requests - Handle incoming API calls
- Extract data - Get parameters, query strings, body data
- Validate input - Ensure data is correct (we'll cover this later)
- Delegate to services - Let business logic handle the work
- Return responses - Send data back to the client
What controllers DON'T do:
- ❌ Business logic (that's for services)
- ❌ Database queries (that's for services/repositories)
- ❌ Complex calculations (that's for services)
- ❌ External API calls (that's for services)
Creating Your First Controller
Let's build a simple controller step by step:
// Step 1: Import necessary decorators
import { Controller, Get } from "@nestjs/common";
// Step 2: Define the controller with @Controller decorator
@Controller("api/products")
// This means all routes start with /api/products
export class ProductsController {
// Step 3: Define a route handler
@Get()
// This handles GET /api/products
findAll() {
return ["Product 1", "Product 2", "Product 3"];
}
}
What's happening here:
@Controller("api/products")
// URL base path: http://localhost:3000/api/products
// └─────────────┘
// This part comes from decorator
export class ProductsController {
@Get()
// Full URL: http://localhost:3000/api/products
// Method: GET
findAll() {
// Whatever you return gets sent as JSON automatically
return ["Product 1", "Product 2", "Product 3"];
}
@Get("featured")
// Full URL: http://localhost:3000/api/products/featured
// Method: GET
getFeatured() {
return ["Featured Product 1"];
}
}
How Routing Works
NestJS combines the controller path with the method path:
Visual representation of route building:
@Controller("users") // Base path
export class UsersController {
@Get() // Route path (empty)
// Full route: GET /users
findAll() {
return "All users";
}
@Get("active") // Route path: 'active'
// Full route: GET /users/active
findActive() {
return "Active users";
}
@Get("profile/:id") // Route path: 'profile/:id'
// Full route: GET /users/profile/123
getProfile(@Param("id") id: string) {
return `Profile for user ${id}`;
}
}
Route combination diagram:
Controller Path + Method Path = Full Route
/users + (nothing) = /users
/users + /active = /users/active
/users + /profile/:id = /users/profile/:id
HTTP Methods: The Complete Guide
HTTP methods define what action you want to perform. Think of them as verbs in a sentence: GET (retrieve), POST (create), PUT (replace), PATCH (update), DELETE (remove).
Visual: HTTP Methods Overview
@Get() - Retrieving Data
Use GET when you want to read data without changing anything:
@Controller("products")
export class ProductsController {
constructor(private productsService: ProductsService) {}
// Get all products
@Get()
// URL: GET /products
findAll() {
return this.productsService.findAll();
// Returns: [{ id: 1, name: 'Laptop' }, { id: 2, name: 'Phone' }]
}
// Get one product by ID
@Get(":id")
// URL: GET /products/5
findOne(@Param("id") id: string) {
return this.productsService.findOne(id);
// Returns: { id: 5, name: 'Tablet', price: 299 }
}
// Get featured products
@Get("featured/top")
// URL: GET /products/featured/top
getFeatured() {
return this.productsService.findFeatured();
// Returns: [{ id: 1, name: 'Best Seller', featured: true }]
}
// Get products by category
@Get("category/:category")
// URL: GET /products/category/electronics
getByCategory(@Param("category") category: string) {
return this.productsService.findByCategory(category);
}
}
GET request characteristics:
- ✅ Should be idempotent (calling it multiple times has the same effect)
- ✅ Should be safe (doesn't modify data)
- ✅ Can be cached by browsers
- ✅ Can be bookmarked
- ✅ Data in URL (query parameters)
Real-world examples:
GET /users → List all users
GET /users/123 → Get user with ID 123
GET /users/123/posts → Get posts by user 123
GET /products?search=laptop → Search products
GET /posts?page=2&limit=10 → Paginated posts
@Post() - Creating New Resources
Use POST when you want to create something new:
@Controller("products")
export class ProductsController {
constructor(private productsService: ProductsService) {}
// Create a new product
@Post()
// URL: POST /products
// Body: { name: "New Product", price: 99 }
create(@Body() createProductDto: CreateProductDto) {
return this.productsService.create(createProductDto);
// Returns: { id: 10, name: "New Product", price: 99, createdAt: "2024-01-01" }
}
// Upload product image
@Post(":id/images")
// URL: POST /products/5/images
uploadImage(@Param("id") id: string, @Body() imageData: any) {
return this.productsService.uploadImage(id, imageData);
}
// Bulk create products
@Post("bulk")
// URL: POST /products/bulk
// Body: [{ name: "Product 1" }, { name: "Product 2" }]
createMany(@Body() products: CreateProductDto[]) {
return this.productsService.createMany(products);
}
}
POST request characteristics:
- ✅ Creates new resources
- ✅ NOT idempotent (calling twice creates two items)
- ✅ Data in request body (not URL)
- ✅ Can send large amounts of data
- ✅ Returns created resource with ID
When to use POST:
POST /users → Create new user
POST /products → Create new product
POST /posts/123/comments → Add comment to post
POST /auth/login → Submit login credentials
POST /orders → Place new order
@Put() - Replacing Resources
Use PUT when you want to completely replace a resource:
@Controller("products")
export class ProductsController {
constructor(private productsService: ProductsService) {}
// Replace entire product
@Put(":id")
// URL: PUT /products/5
// Body: { name: "Updated Product", price: 199, description: "New desc", stock: 50 }
update(@Param("id") id: string, @Body() updateProductDto: UpdateProductDto) {
return this.productsService.replace(id, updateProductDto);
// All fields must be provided - replaces entire resource
}
}
PUT request characteristics:
- ✅ Replaces the entire resource
- ✅ IS idempotent (same result every time)
- ✅ Requires all fields in body
- ✅ Creates resource if doesn't exist (sometimes)
PUT vs POST:
// POST - Creating new (ID assigned by server)
POST /products
Body: { name: "Laptop", price: 999 }
Response: { id: 123, name: "Laptop", price: 999 }
// PUT - Replacing existing (ID in URL)
PUT /products/123
Body: { name: "Gaming Laptop", price: 1299, description: "High-end", stock: 10 }
Response: { id: 123, name: "Gaming Laptop", price: 1299, ... }
// Notice: ALL fields must be provided
@Patch() - Partial Updates
Use PATCH when you want to partially update specific fields:
@Controller("products")
export class ProductsController {
constructor(private productsService: ProductsService) {}
// Update specific fields
@Patch(":id")
// URL: PATCH /products/5
// Body: { price: 149 } ← Only updating price
partialUpdate(
@Param("id") id: string,
@Body() updateData: Partial<UpdateProductDto>
) {
return this.productsService.update(id, updateData);
// Returns: { id: 5, name: "Laptop", price: 149, ... }
// Only price changed, other fields remain the same
}
// Update product status only
@Patch(":id/status")
// URL: PATCH /products/5/status
// Body: { status: "published" }
updateStatus(@Param("id") id: string, @Body("status") status: string) {
return this.productsService.updateStatus(id, status);
}
// Increment view count
@Patch(":id/views")
// URL: PATCH /products/5/views
incrementViews(@Param("id") id: string) {
return this.productsService.incrementViews(id);
// No body needed - just increment the counter
}
}
PATCH request characteristics:
- ✅ Updates only specified fields
- ✅ Other fields remain unchanged
- ✅ More flexible than PUT
- ✅ Commonly used in modern APIs
PATCH vs PUT:
// Existing product:
{
id: 123,
name: "Laptop",
price: 999,
description: "Gaming laptop",
stock: 50
}
// PUT - Must provide ALL fields
PUT /products/123
Body: {
name: "Laptop",
price: 1099, ← Only want to change this
description: "Gaming laptop", ← Must include
stock: 50 ← Must include
}
// PATCH - Only provide what changes
PATCH /products/123
Body: {
price: 1099 ← Only this field
}
// Result: Only price updated, everything else stays the same
@Delete() - Removing Resources
Use DELETE when you want to remove a resource:
@Controller("products")
export class ProductsController {
constructor(private productsService: ProductsService) {}
// Delete a product
@Delete(":id")
// URL: DELETE /products/5
remove(@Param("id") id: string) {
return this.productsService.remove(id);
// Returns: { message: "Product deleted successfully" }
// Or: { deleted: true, id: 5 }
}
// Soft delete (mark as inactive)
@Delete(":id/soft")
// URL: DELETE /products/5/soft
softDelete(@Param("id") id: string) {
return this.productsService.softDelete(id);
// Product still exists but marked as deleted
}
// Delete all products (dangerous!)
@Delete()
// URL: DELETE /products
removeAll() {
return this.productsService.removeAll();
// Usually requires authentication and confirmation
}
// Delete multiple products
@Delete("batch")
// URL: DELETE /products/batch
// Body: { ids: [1, 2, 3, 4, 5] }
removeBatch(@Body("ids") ids: number[]) {
return this.productsService.removeMany(ids);
}
}
DELETE request characteristics:
- ✅ Removes resources
- ✅ IS idempotent (deleting twice has same effect as once)
- ✅ Typically returns success message or deleted resource
- ✅ May return 204 No Content (empty response)
Real-world examples:
DELETE /users/123 → Delete user
DELETE /posts/456 → Delete post
DELETE /cart/items/789 → Remove item from cart
DELETE /sessions/current → Logout (delete session)
DELETE /files/photo.jpg → Delete uploaded file
HTTP Methods Summary
| Method | Purpose | Idempotent? | Safe? | Body? | Example |
|---|---|---|---|---|---|
| GET | Retrieve data | ✅ Yes | ✅ Yes | ❌ No | Get user list |
| POST | Create resource | ❌ No | ❌ No | ✅ Yes | Create user |
| PUT | Replace resource | ✅ Yes | ❌ No | ✅ Yes | Replace user |
| PATCH | Update fields | ✅ Yes* | ❌ No | ✅ Yes | Update email |
| DELETE | Remove resource | ✅ Yes | ❌ No | ❌ No | Delete user |
*PATCH is typically idempotent but depends on implementation
Extracting Data from Requests
Controllers need to access data from various parts of the HTTP request. Let's explore every way to extract data:
Visual: Request Data Flow
Complete HTTP Request Structure:
GET /users/123/posts?page=2&limit=10 HTTP/1.1
│ │ │ │ └─────────────┘
│ │ │ │ └─ Query Parameters (@Query)
│ │ │ └─ URL Parameters (@Param)
│ │ └─ Controller base path
│ └─ HTTP Method (@Get, @Post, etc.)
│
├─ Headers:
│ ├─ Authorization: Bearer token123 (@Headers)
│ ├─ Content-Type: application/json
│ └─ User-Agent: Mozilla/5.0...
│
└─ Body (for POST/PUT/PATCH): (@Body)
{
"title": "New Post",
"content": "Post content here"
}
@Param() - URL Parameters
URL parameters are part of the route path itself:
@Controller('users')
export class UsersController {
// Single parameter
@Get(':id')
// URL: GET /users/123
findOne(@Param('id') id: string) {
console.log(id); // "123"
return this.usersService.findOne(id);
}
// Multiple parameters
@Get(':userId/posts/:postId')
// URL: GET /users/123/posts/456
getPost(
@Param('userId') userId: string,
@Param('postId') postId: string
) {
console.log(userId); // "123"
console.log(postId); // "456"
return this.postsService.findOne(userId, postId);
}
// Get all params as object
@Get(':userId/posts/:postId/comments/:commentId')
// URL: GET /users/123/posts/456/comments/789
getComment(@Param() params: any) {
console.log(params);
// { userId: "123", postId: "456", commentId: "789" }
return this.commentsService.findOne(params);
}
// Type-safe params with interface
interface PostParams {
userId: string;
postId: string;
}
@Get(':userId/posts/:postId')
getPostTypeSafe(@Param() params: PostParams) {
// TypeScript knows params.userId and params.postId exist
return this.postsService.findOne(params.userId, params.postId);
}
}
URL parameter patterns:
/users/:id → /users/123
/users/:id/posts/:postId → /users/123/posts/456
/categories/:category/items/:id → /categories/electronics/items/789
/:version/api/users/:id → /v1/api/users/123
Key points:
- Parameters are always strings (convert to numbers if needed)
- Parameter names match the route definition
- Use meaningful parameter names (
userIdnot justid)
@Query() - Query String Parameters
Query parameters come after ? in the URL:
@Controller('products')
export class ProductsController {
// Single query parameter
@Get()
// URL: GET /products?search=laptop
findAll(@Query('search') search: string) {
console.log(search); // "laptop"
return this.productsService.search(search);
}
// Multiple query parameters
@Get()
// URL: GET /products?category=electronics&minPrice=100&maxPrice=1000
filter(
@Query('category') category: string,
@Query('minPrice') minPrice: string,
@Query('maxPrice') maxPrice: string
) {
// Convert strings to numbers
return this.productsService.filter(
category,
parseInt(minPrice),
parseInt(maxPrice)
);
}
// Get all query params as object
@Get()
// URL: GET /products?page=2&limit=20&sort=price&order=asc
findAllWithPagination(@Query() query: any) {
console.log(query);
// {
// page: "2",
// limit: "20",
// sort: "price",
// order: "asc"
// }
return this.productsService.paginate(query);
}
// Type-safe query params
interface QueryParams {
page?: string;
limit?: string;
search?: string;
category?: string;
}
@Get()
findWithTypeSafety(@Query() query: QueryParams) {
const page = parseInt(query.page) || 1;
const limit = parseInt(query.limit) || 10;
return this.productsService.paginate({
page,
limit,
search: query.search,
category: query.category
});
}
}
Query parameter examples:
?search=laptop → Single parameter
?page=2&limit=10 → Pagination
?sort=price&order=desc → Sorting
?filter=active&category=tech → Filtering
?fields=name,price,stock → Field selection
Params vs Query:
// URL Parameters (part of route)
GET /users/123/posts/456
@Get(':userId/posts/:postId')
getPost(@Param('userId') userId, @Param('postId') postId)
// Query Parameters (optional filters)
GET /posts?author=123&status=published&page=2
@Get()
getPosts(@Query('author') author, @Query('status') status, @Query('page') page)
@Body() - Request Body
The request body contains data sent with POST, PUT, PATCH requests:
@Controller("users")
export class UsersController {
// Extract entire body
@Post()
// Body: { name: "John", email: "john@example.com", age: 30 }
create(@Body() createUserDto: CreateUserDto) {
console.log(createUserDto);
// { name: "John", email: "john@example.com", age: 30 }
return this.usersService.create(createUserDto);
}
// Extract specific field from body
@Post()
// Body: { name: "John", email: "john@example.com", password: "secret123" }
createWithFields(
@Body("name") name: string,
@Body("email") email: string,
@Body("password") password: string
) {
console.log(name); // "John"
console.log(email); // "john@example.com"
console.log(password); // "secret123"
return this.usersService.create({ name, email, password });
}
// Complex nested body
@Post("profile")
// Body: {
// name: "John",
// email: "john@example.com",
// address: {
// street: "123 Main St",
// city: "New York",
// country: "USA"
// },
// preferences: {
// newsletter: true,
// notifications: false
// }
// }
createProfile(@Body() profileData: CreateProfileDto) {
console.log(profileData.address.city); // "New York"
console.log(profileData.preferences.newsletter); // true
return this.usersService.createProfile(profileData);
}
// Array in body
@Post("bulk")
// Body: [
// { name: "User1", email: "user1@example.com" },
// { name: "User2", email: "user2@example.com" }
// ]
createMany(@Body() users: CreateUserDto[]) {
console.log(users.length); // 2
return this.usersService.createMany(users);
}
// Combining body with params
@Patch(":id")
// URL: PATCH /users/123
// Body: { name: "Updated Name" }
update(@Param("id") id: string, @Body() updateData: UpdateUserDto) {
return this.usersService.update(id, updateData);
}
}
Body data examples:
// Simple object
{ "name": "John", "email": "john@example.com" }
// Nested object
{
"user": {
"name": "John",
"profile": {
"age": 30,
"location": "NYC"
}
}
}
// Array
[
{ "id": 1, "name": "Item 1" },
{ "id": 2, "name": "Item 2" }
]
// Mixed
{
"action": "update",
"users": [123, 456, 789],
"data": { "status": "active" }
}
@Headers() - HTTP Headers
Headers contain metadata about the request:
@Controller("api")
export class ApiController {
// Extract specific header
@Get("protected")
getProtectedData(@Headers("authorization") authorization: string) {
console.log(authorization); // "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
// Verify token
const token = authorization.replace("Bearer ", "");
return this.authService.validateToken(token);
}
// Get all headers
@Get("debug")
debug(@Headers() headers: any) {
console.log(headers);
// {
// 'content-type': 'application/json',
// 'user-agent': 'Mozilla/5.0...',
// 'authorization': 'Bearer token...',
// 'accept': '*/*',
// 'host': 'localhost:3000'
// }
return { headers };
}
// Multiple headers
@Post("upload")
uploadFile(
@Headers("content-type") contentType: string,
@Headers("content-length") contentLength: string,
@Headers("authorization") auth: string,
@Body() data: any
) {
console.log(contentType); // "multipart/form-data"
console.log(contentLength); // "1024"
console.log(auth); // "Bearer token..."
return this.fileService.upload(data, contentType);
}
// Custom headers
@Get("api-key-auth")
apiKeyAuth(@Headers("x-api-key") apiKey: string) {
if (!this.validateApiKey(apiKey)) {
throw new UnauthorizedException("Invalid API key");
}
return { success: true };
}
}
Common headers:
Authorization: Bearer eyJhbGci... → Authentication token
Content-Type: application/json → Body format
Accept: application/json → Desired response format
User-Agent: Mozilla/5.0... → Client information
X-API-Key: abc123... → Custom API key
X-Request-ID: uuid-here → Request tracking
@Req() and @Res() - Full Request/Response Objects
Sometimes you need access to the raw Express request/response:
import { Controller, Get, Req, Res } from "@nestjs/common";
import { Request, Response } from "express";
@Controller("advanced")
export class AdvancedController {
// Access full request object
@Get("request-details")
getRequestDetails(@Req() request: Request) {
return {
method: request.method, // GET
url: request.url, // /advanced/request-details
headers: request.headers, // All headers
query: request.query, // Query parameters
params: request.params, // URL parameters
body: request.body, // Request body
ip: request.ip, // Client IP
hostname: request.hostname, // Host
protocol: request.protocol, // http or https
originalUrl: request.originalUrl, // Full original URL
};
}
// Manually control response
@Get("custom-response")
customResponse(@Res() response: Response) {
// Warning: When you use @Res(), you must send the response manually
response
.status(200)
.header("X-Custom-Header", "MyValue")
.json({ message: "Custom response" });
}
// Set cookies
@Get("set-cookie")
setCookie(@Res() response: Response) {
response.cookie("session_id", "abc123", {
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000, // 1 day
});
response.json({ message: "Cookie set" });
}
// Download file
@Get("download")
downloadFile(@Res() response: Response) {
const file = "/path/to/file.pdf";
response.download(file);
}
// Stream response
@Get("stream")
streamData(@Res() response: Response) {
response.setHeader("Content-Type", "text/event-stream");
response.setHeader("Cache-Control", "no-cache");
const interval = setInterval(() => {
response.write(`data: ${Date.now()}\n\n`);
}, 1000);
// Clean up on client disconnect
response.on("close", () => {
clearInterval(interval);
});
}
}
⚠️ Important about @Res():
// ❌ Wrong: Using @Res() prevents automatic JSON serialization
@Get()
getData(@Res() response: Response) {
return { data: 'This wont work!' };
// Response never sent!
}
// ✅ Right: Manually send response when using @Res()
@Get()
getData(@Res() response: Response) {
response.json({ data: 'This works!' });
}
// ✅ Best: Don't use @Res() unless you need manual control
@Get()
getData() {
return { data: 'This automatically becomes JSON!' };
// NestJS handles response serialization
}
Route Patterns and Wildcards
NestJS supports powerful route matching patterns:
Exact Matches
@Controller("products")
export class ProductsController {
@Get("featured")
// Matches: GET /products/featured
// Does NOT match: GET /products/featured/top
getFeatured() {
return "Featured products";
}
@Get("on-sale")
// Matches: GET /products/on-sale
getOnSale() {
return "Products on sale";
}
}
Wildcard Routes
Use wildcards to match multiple patterns:
@Controller("files")
export class FilesController {
// Asterisk matches any characters
@Get("*.pdf")
// Matches:
// - /files/document.pdf
// - /files/report.pdf
// - /files/my-file.pdf
// Does NOT match:
// - /files/image.jpg
// - /files/document.pdf.backup
getPdfFiles(@Req() request: Request) {
console.log(request.url); // The full matched URL
return "PDF file handler";
}
// Question mark matches exactly one character
@Get("file-?.txt")
// Matches:
// - /files/file-1.txt
// - /files/file-a.txt
// - /files/file-x.txt
// Does NOT match:
// - /files/file-10.txt (two characters)
// - /files/file.txt (no character)
getFileWithOneChar() {
return "Single character file";
}
// Parentheses for grouping (character sets)
@Get("file-[abc].txt")
// Matches:
// - /files/file-a.txt
// - /files/file-b.txt
// - /files/file-c.txt
// Does NOT match:
// - /files/file-d.txt
getFileWithSpecificChar() {
return "Specific character file";
}
}
Catch-All Routes
Sometimes you want a fallback route:
@Controller()
export class AppController {
// Regular routes first
@Get("about")
about() {
return "About page";
}
@Get("contact")
contact() {
return "Contact page";
}
// Catch-all route (must be LAST)
@Get("*")
// Matches any route not matched above
notFound() {
return "Page not found - 404";
}
}
Route Order Matters!
Critical concept: Routes are matched in the order they're defined:
❌ Wrong order - 'me' will never be reached:
@Controller("users")
export class UsersController {
@Get(":id")
// This matches ANY string including "me"
findOne(@Param("id") id: string) {
return `User ${id}`;
}
@Get("me")
// This will NEVER execute because :id matches first
getCurrentUser() {
return "Current user";
}
}
// When you visit /users/me:
// 1. Router checks @Get(':id') - MATCHES! (id = "me")
// 2. Returns `User me` instead of calling getCurrentUser()
✅ Correct order with proper flow:
Correct implementation:
@Controller("users")
export class UsersController {
// Specific routes FIRST
@Get("me")
// Matches: GET /users/me
getCurrentUser() {
return "Current user profile";
}
@Get("active")
// Matches: GET /users/active
getActiveUsers() {
return "Active users";
}
@Get("admin")
// Matches: GET /users/admin
getAdmins() {
return "Admin users";
}
// Dynamic routes LAST
@Get(":id")
// Matches: GET /users/123, /users/abc, etc.
// But NOT /users/me, /users/active, /users/admin (already matched above)
findOne(@Param("id") id: string) {
return `User ${id}`;
}
}
Route matching priority diagram:
Rule of thumb:
- Static routes first (
/users/me,/users/active) - Dynamic routes last (
/users/:id) - Catch-all routes absolutely last (
*)
Complex Route Patterns
@Controller("api")
export class ApiController {
// Multiple path segments
@Get("v1/users/:userId/posts/:postId/comments")
// Matches: /api/v1/users/123/posts/456/comments
getComments(
@Param("userId") userId: string,
@Param("postId") postId: string
) {
return this.commentsService.find(userId, postId);
}
// Optional segments using wildcards
@Get("search/*")
// Matches:
// - /api/search/users
// - /api/search/products
// - /api/search/anything
search(@Req() req: Request) {
const searchPath = req.path.replace("/api/search/", "");
return this.searchService.search(searchPath);
}
// Regex-like patterns
@Get("files/:filename([a-z0-9-]+).pdf")
// Matches: /api/files/my-document-123.pdf
// Does NOT match: /api/files/My_Document.pdf (uppercase, underscore)
getPdfFile(@Param("filename") filename: string) {
return this.fileService.getPdf(filename);
}
}
Status Codes and Custom Responses
HTTP status codes tell the client what happened with their request.
Visual: HTTP Status Code Categories
Request/Response Flow with Status Codes
Default Status Codes
NestJS automatically sets appropriate status codes:
@Controller("products")
export class ProductsController {
@Get()
// Default: 200 OK
findAll() {
return ["Product 1", "Product 2"];
}
@Post()
// Default: 201 Created (for POST requests)
create(@Body() data: any) {
return { id: 1, ...data };
}
@Get(":id")
// Default: 200 OK
// But if not found, throw exception (see below)
findOne(@Param("id") id: string) {
const product = this.productsService.findOne(id);
if (!product) {
throw new NotFoundException("Product not found");
// Automatically returns 404
}
return product;
}
}
Custom Status Codes
Use @HttpCode() decorator to set custom status codes:
import {
Controller,
Post,
Delete,
HttpCode,
Body,
Param,
} from "@nestjs/common";
@Controller("users")
export class UsersController {
@Post()
@HttpCode(201) // Created
create(@Body() userData: any) {
return this.usersService.create(userData);
}
@Delete(":id")
@HttpCode(204) // No Content (successful deletion with no response body)
remove(@Param("id") id: string) {
this.usersService.remove(id);
// No return statement - 204 means "success but no content"
}
@Post("logout")
@HttpCode(200) // OK (instead of default 201 for POST)
logout() {
return { message: "Logged out successfully" };
}
@Post("verify-email")
@HttpCode(202) // Accepted (request accepted but not yet processed)
verifyEmail(@Body("token") token: string) {
// Email verification happens asynchronously
this.emailService.queueVerification(token);
return { message: "Verification email sent" };
}
}
Common HTTP Status Codes
Success codes (2xx):
200 OK - Standard success response
201 Created - Resource created (default for POST)
202 Accepted - Request accepted, processing async
204 No Content - Success, but no response body
Client error codes (4xx):
400 Bad Request - Invalid request data
401 Unauthorized - Authentication required
403 Forbidden - Authenticated but not allowed
404 Not Found - Resource doesn't exist
409 Conflict - Resource conflict (duplicate email, etc.)
422 Unprocessable - Validation failed
429 Too Many Requests - Rate limit exceeded
Server error codes (5xx):
500 Internal Server Error - Something went wrong on server
502 Bad Gateway - Upstream service error
503 Service Unavailable - Server temporarily down
Exception Classes (Built-in)
NestJS provides exception classes that automatically set the correct status code:
import {
BadRequestException,
UnauthorizedException,
NotFoundException,
ForbiddenException,
ConflictException,
InternalServerErrorException,
} from "@nestjs/common";
@Controller("users")
export class UsersController {
@Get(":id")
findOne(@Param("id") id: string) {
const user = this.usersService.findOne(id);
if (!user) {
// Automatically returns 404
throw new NotFoundException("User not found");
}
return user;
}
@Post()
create(@Body() userData: CreateUserDto) {
const existingUser = this.usersService.findByEmail(userData.email);
if (existingUser) {
// Automatically returns 409
throw new ConflictException("Email already exists");
}
return this.usersService.create(userData);
}
@Post("login")
login(@Body() credentials: LoginDto) {
const user = this.authService.validateCredentials(credentials);
if (!user) {
// Automatically returns 401
throw new UnauthorizedException("Invalid credentials");
}
return this.authService.generateToken(user);
}
@Delete(":id")
remove(@Param("id") id: string, @Headers("authorization") auth: string) {
if (!this.authService.isAdmin(auth)) {
// Automatically returns 403
throw new ForbiddenException("Only admins can delete users");
}
return this.usersService.remove(id);
}
@Post()
createWithValidation(@Body() userData: CreateUserDto) {
if (!userData.email || !userData.password) {
// Automatically returns 400
throw new BadRequestException("Email and password are required");
}
return this.usersService.create(userData);
}
}
Custom Error Messages
@Controller("products")
export class ProductsController {
@Get(":id")
findOne(@Param("id") id: string) {
const product = this.productsService.findOne(id);
if (!product) {
// Simple message
throw new NotFoundException("Product not found");
// Or detailed message object
throw new NotFoundException({
statusCode: 404,
message: "Product not found",
error: "Not Found",
details: {
productId: id,
timestamp: new Date().toISOString(),
},
});
}
return product;
}
@Post()
create(@Body() data: CreateProductDto) {
try {
return this.productsService.create(data);
} catch (error) {
// Custom error handling
if (error.code === "DUPLICATE_KEY") {
throw new ConflictException("Product with this SKU already exists");
}
// Generic error
throw new InternalServerErrorException("Failed to create product");
}
}
}
Async Operations in Controllers
Most real-world operations are asynchronous (database queries, API calls, etc.):
Visual: Async Request Processing
Using async/await
@Controller("users")
export class UsersController {
constructor(private usersService: UsersService) {}
// Async controller method
@Get()
async findAll(): Promise<User[]> {
// Wait for database query to complete
const users = await this.usersService.findAll();
return users;
// NestJS automatically waits for the promise to resolve
}
@Get(":id")
async findOne(@Param("id") id: string): Promise<User> {
const user = await this.usersService.findOne(id);
if (!user) {
throw new NotFoundException("User not found");
}
return user;
}
@Post()
async create(@Body() userData: CreateUserDto): Promise<User> {
// Multiple async operations
const existingUser = await this.usersService.findByEmail(userData.email);
if (existingUser) {
throw new ConflictException("Email already exists");
}
// Create user
const user = await this.usersService.create(userData);
// Send welcome email (don't wait for it)
this.emailService.sendWelcome(user.email).catch((err) => {
console.error("Failed to send welcome email:", err);
});
return user;
}
@Post("bulk")
async createMany(@Body() users: CreateUserDto[]): Promise<User[]> {
// Process multiple items in parallel
const createdUsers = await Promise.all(
users.map((userData) => this.usersService.create(userData))
);
return createdUsers;
}
}
Error Handling in Async Controllers
@Controller("api")
export class ApiController {
@Get("external-data")
async fetchExternalData(): Promise<any> {
try {
// Call external API
const response = await this.httpService.get(
"https://api.example.com/data"
);
return response.data;
} catch (error) {
// Handle different error scenarios
if (error.response?.status === 404) {
throw new NotFoundException("External resource not found");
}
if (error.code === "ETIMEDOUT") {
throw new RequestTimeoutException("External API timeout");
}
// Generic error
throw new InternalServerErrorException("Failed to fetch external data");
}
}
@Post("process")
async processData(@Body() data: any): Promise<any> {
// Long-running operation with timeout
const result = await Promise.race([
this.dataService.process(data),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), 30000)
),
]);
return result;
}
@Get("parallel-operations")
async parallelOps(): Promise<any> {
// Run multiple operations in parallel
const [users, products, orders] = await Promise.all([
this.usersService.findAll(),
this.productsService.findAll(),
this.ordersService.findAll(),
]);
return { users, products, orders };
}
}
Visual: Parallel vs Sequential Operations
Code comparison:
// ❌ Sequential - Takes 450ms
@Get('slow')
async getDataSequential() {
const users = await this.usersService.findAll(); // 100ms
const products = await this.productsService.findAll(); // 150ms
const orders = await this.ordersService.findAll(); // 200ms
return { users, products, orders }; // Total: 450ms
}
// ✅ Parallel - Takes 200ms (slowest operation)
@Get('fast')
async getDataParallel() {
const [users, products, orders] = await Promise.all([
this.usersService.findAll(), // 100ms ┐
this.productsService.findAll(), // 150ms ├─ All run at once
this.ordersService.findAll() // 200ms ┘
]);
return { users, products, orders }; // Total: 200ms
}
Returning Promises Directly
@Controller("users")
export class UsersController {
// You can return promises directly (NestJS will wait)
@Get()
findAll(): Promise<User[]> {
// No async/await needed if you're just returning the promise
return this.usersService.findAll();
}
@Get(":id")
findOne(@Param("id") id: string): Promise<User> {
return this.usersService.findOne(id);
}
// But async/await is better when you need to process the result
@Get(":id")
async findOneWithCheck(@Param("id") id: string): Promise<User> {
const user = await this.usersService.findOne(id);
// Can check the result before returning
if (!user) {
throw new NotFoundException();
}
return user;
}
}
Response Manipulation
Setting Custom Headers
import { Controller, Get, Header, Res } from "@nestjs/common";
import { Response } from "express";
@Controller("api")
export class ApiController {
// Using @Header decorator
@Get("csv-export")
@Header("Content-Type", "text/csv")
@Header("Content-Disposition", 'attachment; filename="export.csv"')
exportCsv() {
return "id,name,email\n1,John,john@example.com\n2,Jane,jane@example.com";
}
// Using response object
@Get("download")
download(@Res() response: Response) {
response.setHeader("Content-Type", "application/pdf");
response.setHeader(
"Content-Disposition",
'attachment; filename="document.pdf"'
);
response.send(pdfBuffer);
}
// Custom headers
@Get("rate-limit-info")
@Header("X-RateLimit-Limit", "100")
@Header("X-RateLimit-Remaining", "99")
@Header("X-RateLimit-Reset", "1640000000")
getRateLimitInfo() {
return { limit: 100, remaining: 99 };
}
}
Redirects
import { Controller, Get, Redirect } from "@nestjs/common";
@Controller()
export class AppController {
// Simple redirect
@Get("old-url")
@Redirect("https://example.com/new-url", 301)
redirectOld() {
// This method won't execute - redirect happens immediately
}
// Conditional redirect
@Get("docs")
@Redirect("https://docs.example.com", 302)
getDocs(@Query("version") version: string) {
if (version && version === "v2") {
// Override the default redirect
return { url: "https://docs.example.com/v2" };
}
// Use default redirect to https://docs.example.com
}
// Dynamic redirect
@Get("short/:code")
@Redirect()
async shortUrl(@Param("code") code: string) {
const url = await this.urlService.findByCode(code);
if (!url) {
throw new NotFoundException("Short URL not found");
}
return { url: url.longUrl, statusCode: 302 };
}
}
Streaming Responses
import { Controller, Get, Res, StreamableFile } from "@nestjs/common";
import { Response } from "express";
import { createReadStream } from "fs";
@Controller("files")
export class FilesController {
// Using StreamableFile (recommended)
@Get("download/:filename")
getFile(@Param("filename") filename: string): StreamableFile {
const file = createReadStream(`./uploads/${filename}`);
return new StreamableFile(file);
}
// Manual streaming
@Get("stream/:filename")
streamFile(@Param("filename") filename: string, @Res() response: Response) {
const file = createReadStream(`./uploads/${filename}`);
file.pipe(response);
}
// Server-Sent Events (SSE)
@Get("events")
@Header("Content-Type", "text/event-stream")
@Header("Cache-Control", "no-cache")
@Header("Connection", "keep-alive")
events(@Res() response: Response) {
// Send events every second
const interval = setInterval(() => {
const event = {
data: { timestamp: Date.now(), message: "Hello!" },
};
response.write(`data: ${JSON.stringify(event)}\n\n`);
}, 1000);
// Cleanup on disconnect
response.on("close", () => {
clearInterval(interval);
response.end();
});
}
}
Common Misconceptions
❌ Misconception: "Controllers should contain all my logic"
Reality: Controllers should be thin - they route requests to services that contain business logic.
Why this matters: Mixing concerns makes code hard to test, reuse, and maintain.
Example:
// ❌ Bad: Logic in controller
@Controller("users")
export class UsersController {
@Post()
async create(@Body() userData: CreateUserDto) {
// Validation logic
if (!userData.email || !userData.email.includes("@")) {
throw new BadRequestException("Invalid email");
}
// Business logic
const existingUser = await database.query(
"SELECT * FROM users WHERE email = ?",
[userData.email]
);
if (existingUser) {
throw new ConflictException("Email exists");
}
// Password hashing
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(userData.password, salt);
// Database operation
const user = await database.query(
"INSERT INTO users (email, password) VALUES (?, ?)",
[userData.email, hashedPassword]
);
// Email sending
await sendEmail(userData.email, "Welcome!");
return user;
}
}
// ✅ Good: Thin controller, logic in service
@Controller("users")
export class UsersController {
constructor(private usersService: UsersService) {}
@Post()
create(@Body() userData: CreateUserDto) {
// Controller just routes - service handles everything
return this.usersService.create(userData);
}
}
@Injectable()
export class UsersService {
async create(userData: CreateUserDto): Promise<User> {
// All logic here
await this.validateEmail(userData.email);
await this.checkDuplicate(userData.email);
const hashedPassword = await this.hashPassword(userData.password);
const user = await this.saveToDatabase(userData, hashedPassword);
await this.sendWelcomeEmail(user.email);
return user;
}
}
❌ Misconception: "Param values are automatically converted to numbers"
Reality: All URL parameters and query strings are strings - you must convert them manually.
Why this matters: Using strings where numbers are expected causes bugs.
Example:
// ❌ Wrong: Treating param as number
@Get(':id')
findOne(@Param('id') id: string) {
// id is "123" (string), not 123 (number)
const user = this.usersService.findOne(id);
// If service expects number, this might fail
// "123" !== 123
}
// ✅ Right: Convert to number
@Get(':id')
findOne(@Param('id') id: string) {
const numericId = parseInt(id, 10);
if (isNaN(numericId)) {
throw new BadRequestException('ID must be a number');
}
return this.usersService.findOne(numericId);
}
// ✅ Better: Use ParseIntPipe (covered in future article)
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
// id is automatically converted to number
// Throws 400 if conversion fails
return this.usersService.findOne(id);
}
❌ Misconception: "Using @Res() is the best practice"
Reality: Avoid @Res() unless you need manual control - it prevents NestJS features from working.
Why this matters: Using @Res() disables automatic response handling, serialization, and interceptors.
Example:
// ❌ Bad: Unnecessary use of @Res()
@Get()
findAll(@Res() response: Response) {
const users = this.usersService.findAll();
response.json(users); // Manual response
}
// ✅ Good: Let NestJS handle it
@Get()
findAll() {
return this.usersService.findAll();
// Automatically serialized to JSON
// Interceptors and transforms work
// Much simpler!
}
// ✅ Use @Res() only when necessary
@Get('download')
download(@Res() response: Response) {
// Need manual control for file download
response.download('/path/to/file.pdf');
}
❌ Misconception: "Route order doesn't matter"
Reality: Routes are matched from top to bottom - order is critical.
Why this matters: A catch-all route defined too early will prevent specific routes from ever matching.
Example:
// ❌ Wrong order
@Controller("users")
export class UsersController {
@Get(":id") // Matches EVERYTHING
findOne(@Param("id") id: string) {
return this.usersService.findOne(id);
}
@Get("me") // NEVER REACHED!
getCurrentUser() {
return "Current user";
}
}
// When you visit /users/me:
// - First route matches (id = "me")
// - Second route never checked
// ✅ Correct order
@Controller("users")
export class UsersController {
@Get("me") // Specific routes first
getCurrentUser() {
return "Current user";
}
@Get(":id") // Dynamic routes last
findOne(@Param("id") id: string) {
return this.usersService.findOne(id);
}
}
Troubleshooting Common Issues
Problem: "Cannot GET /api/users"
Symptoms: 404 error when accessing your endpoint
Common Causes:
- Module not imported (70%)
- Wrong route path (20%)
- Server not running (10%)
Solution:
// Step 1: Verify controller is registered in module
@Module({
controllers: [UsersController], // ✅ Must be here
providers: [UsersService],
})
export class UsersModule {}
// Step 2: Verify module is imported in AppModule
@Module({
imports: [UsersModule], // ✅ Must be here
})
export class AppModule {}
// Step 3: Check route paths
@Controller("users") // Base: /users
export class UsersController {
@Get() // Full path: /users (not /api/users unless specified)
findAll() {
return [];
}
}
// Step 4: Check server is running
// npm run start:dev should be running
Problem: "Cannot read property 'findOne' of undefined"
Symptoms: Service method is undefined
Common Causes:
- Service not injected (80%)
- Service not in providers array (15%)
- Circular dependency (5%)
Solution:
// Check service is properly injected
@Controller("users")
export class UsersController {
constructor(
private usersService: UsersService // ✅ Injected here // Note: "private" is required for property creation
) {}
@Get()
findAll() {
// this.usersService is now available
return this.usersService.findAll();
}
}
// Check service is in module providers
@Module({
controllers: [UsersController],
providers: [UsersService], // ✅ Service must be here
})
export class UsersModule {}
Problem: "JSON response wraps my data in extra object"
Symptoms: Expected [] but got { data: [] }
Common Causes:
- Returning wrapped response (100%)
Solution:
// ❌ Wrong: Wrapping data unnecessarily
@Get()
findAll() {
const users = this.usersService.findAll();
return { data: users }; // Creates: { data: [...] }
}
// ✅ Right: Return data directly
@Get()
findAll() {
return this.usersService.findAll(); // Returns: [...]
}
// ✅ If you need metadata, include it
@Get()
async findAll(@Query() query: any) {
const users = await this.usersService.findAll(query);
const total = await this.usersService.count();
return {
data: users,
total,
page: query.page,
limit: query.limit
};
}
Check Your Understanding
Quick Quiz
-
What's the difference between @Param() and @Query()?
Show Answer
@Param() - Extracts URL path parameters:
@Get(':id')
findOne(@Param('id') id: string)
// URL: /users/123
// id = "123"@Query() - Extracts query string parameters:
@Get()
findAll(@Query('page') page: string)
// URL: /users?page=2
// page = "2"Key difference: Params are part of the route path, queries come after
? -
Why is route order important?
Show Answer
Routes are matched from top to bottom. If a dynamic route (
:id) is defined before a static route (me), the dynamic route will match everything including "me".Always put:
- Static routes first (
/users/me,/users/active) - Dynamic routes last (
/users/:id) - Catch-all routes absolutely last (
*)
- Static routes first (
-
What HTTP method should you use to partially update a resource?
Show Answer
PATCH is for partial updates (only specified fields change).
@Patch(':id')
update(@Param('id') id: string, @Body() data: Partial<UpdateDto>) {
// Only update fields provided in data
}PUT is for full replacement (all fields must be provided).
-
What happens when you throw NotFoundException?
Show Answer
NestJS automatically:
- Sets response status to 404
- Returns JSON error response:
{
"statusCode": 404,
"message": "Not Found",
"error": "Not Found"
}- Stops further execution
- Sends response to client
You don't need to manually handle the response.
Hands-On Exercise
Challenge: Create a books API with full CRUD operations.
Requirements:
- Create BooksController with these endpoints:
- GET /books - List all books
- GET /books/:id - Get one book
- GET /books/author/:author - Get books by author
- POST /books - Create a book
- PATCH /books/:id - Update a book
- DELETE /books/:id - Delete a book
- Use proper HTTP methods and status codes
- Extract parameters correctly
Show Solution
// books.controller.ts
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
Query,
HttpCode,
NotFoundException,
} from "@nestjs/common";
interface Book {
id: number;
title: string;
author: string;
year: number;
}
@Controller("books")
export class BooksController {
private books: Book[] = [
{
id: 1,
title: "The Great Gatsby",
author: "F. Scott Fitzgerald",
year: 1925,
},
{ id: 2, title: "1984", author: "George Orwell", year: 1949 },
{ id: 3, title: "To Kill a Mockingbird", author: "Harper Lee", year: 1960 },
];
// GET /books - List all books
@Get()
findAll(@Query("search") search?: string) {
if (search) {
return this.books.filter((book) =>
book.title.toLowerCase().includes(search.toLowerCase())
);
}
return this.books;
}
// GET /books/author/:author - Get books by author (specific route first!)
@Get("author/:author")
findByAuthor(@Param("author") author: string) {
const books = this.books.filter((book) =>
book.author.toLowerCase().includes(author.toLowerCase())
);
if (books.length === 0) {
throw new NotFoundException(`No books found by author: ${author}`);
}
return books;
}
// GET /books/:id - Get one book (dynamic route last)
@Get(":id")
findOne(@Param("id") id: string) {
const numericId = parseInt(id, 10);
if (isNaN(numericId)) {
throw new BadRequestException("ID must be a number");
}
const book = this.books.find((b) => b.id === numericId);
if (!book) {
throw new NotFoundException(`Book with ID ${id} not found`);
}
return book;
}
// POST /books - Create a book
@Post()
@HttpCode(201)
create(@Body() bookData: Omit<Book, "id">) {
const newBook: Book = {
id: Math.max(...this.books.map((b) => b.id)) + 1,
...bookData,
};
this.books.push(newBook);
return newBook;
}
// PATCH /books/:id - Update a book
@Patch(":id")
update(
@Param("id") id: string,
@Body() updateData: Partial<Omit<Book, "id">>
) {
const numericId = parseInt(id, 10);
const bookIndex = this.books.findIndex((b) => b.id === numericId);
if (bookIndex === -1) {
throw new NotFoundException(`Book with ID ${id} not found`);
}
this.books[bookIndex] = {
...this.books[bookIndex],
...updateData,
};
return this.books[bookIndex];
}
// DELETE /books/:id - Delete a book
@Delete(":id")
@HttpCode(204)
remove(@Param("id") id: string) {
const numericId = parseInt(id, 10);
const bookIndex = this.books.findIndex((b) => b.id === numericId);
if (bookIndex === -1) {
throw new NotFoundException(`Book with ID ${id} not found`);
}
this.books.splice(bookIndex, 1);
// No return - 204 means success with no content
}
}
Testing the API:
# List all books
curl http://localhost:3000/books
# Search books
curl http://localhost:3000/books?search=gatsby
# Get one book
curl http://localhost:3000/books/1
# Get books by author
curl http://localhost:3000/books/author/orwell
# Create a book
curl -X POST http://localhost:3000/books \
-H "Content-Type: application/json" \
-d '{"title":"New Book","author":"John Doe","year":2024}'
# Update a book
curl -X PATCH http://localhost:3000/books/1 \
-H "Content-Type: application/json" \
-d '{"year":1926}'
# Delete a book
curl -X DELETE http://localhost:3000/books/1
Explanation:
- Static route
/books/author/:authorcomes before dynamic/books/:id - Proper HTTP methods for each operation
- Correct status codes (201 for POST, 204 for DELETE)
- Parameter validation and error handling
- Search functionality with query parameters
Real-World Controller Patterns
Let's explore common patterns you'll use in production applications:
Pagination Pattern
@Controller("posts")
export class PostsController {
@Get()
async findAll(
@Query("page") page: string = "1",
@Query("limit") limit: string = "10",
@Query("sort") sort: string = "createdAt",
@Query("order") order: "ASC" | "DESC" = "DESC"
) {
const pageNum = parseInt(page, 10);
const limitNum = parseInt(limit, 10);
// Validate pagination params
if (pageNum < 1 || limitNum < 1 || limitNum > 100) {
throw new BadRequestException("Invalid pagination parameters");
}
const [posts, total] = await this.postsService.paginate({
page: pageNum,
limit: limitNum,
sort,
order,
});
return {
data: posts,
meta: {
total,
page: pageNum,
limit: limitNum,
totalPages: Math.ceil(total / limitNum),
hasNextPage: pageNum * limitNum < total,
hasPrevPage: pageNum > 1,
},
};
}
}
// Usage:
// GET /posts?page=2&limit=20&sort=title&order=ASC
Filtering Pattern
@Controller("products")
export class ProductsController {
@Get()
async findAll(
@Query("category") category?: string,
@Query("minPrice") minPrice?: string,
@Query("maxPrice") maxPrice?: string,
@Query("inStock") inStock?: string,
@Query("search") search?: string
) {
const filters: any = {};
if (category) filters.category = category;
if (minPrice) filters.minPrice = parseFloat(minPrice);
if (maxPrice) filters.maxPrice = parseFloat(maxPrice);
if (inStock !== undefined) filters.inStock = inStock === "true";
if (search) filters.search = search;
return this.productsService.findAll(filters);
}
}
// Usage:
// GET /products?category=electronics&minPrice=100&maxPrice=1000&inStock=true&search=laptop
Nested Resources Pattern
@Controller("users/:userId/posts")
export class UserPostsController {
constructor(
private usersService: UsersService,
private postsService: PostsService
) {}
// GET /users/123/posts
@Get()
async findUserPosts(@Param("userId") userId: string) {
const user = await this.usersService.findOne(userId);
if (!user) {
throw new NotFoundException("User not found");
}
return this.postsService.findByUserId(userId);
}
// POST /users/123/posts
@Post()
async createUserPost(
@Param("userId") userId: string,
@Body() postData: CreatePostDto
) {
const user = await this.usersService.findOne(userId);
if (!user) {
throw new NotFoundException("User not found");
}
return this.postsService.create({
...postData,
userId,
});
}
// GET /users/123/posts/456
@Get(":postId")
async findUserPost(
@Param("userId") userId: string,
@Param("postId") postId: string
) {
const post = await this.postsService.findOne(postId);
if (!post) {
throw new NotFoundException("Post not found");
}
// Verify post belongs to user
if (post.userId !== userId) {
throw new NotFoundException("Post not found for this user");
}
return post;
}
}
Bulk Operations Pattern
@Controller("products")
export class ProductsController {
// Bulk create
@Post("bulk")
async createMany(@Body() products: CreateProductDto[]) {
if (!Array.isArray(products) || products.length === 0) {
throw new BadRequestException("Products array is required");
}
if (products.length > 100) {
throw new BadRequestException(
"Cannot create more than 100 products at once"
);
}
return this.productsService.createMany(products);
}
// Bulk update
@Patch("bulk")
async updateMany(
@Body() updates: { id: number; data: Partial<UpdateProductDto> }[]
) {
return this.productsService.updateMany(updates);
}
// Bulk delete
@Delete("bulk")
@HttpCode(204)
async removeMany(@Body("ids") ids: number[]) {
if (!Array.isArray(ids) || ids.length === 0) {
throw new BadRequestException("IDs array is required");
}
await this.productsService.removeMany(ids);
}
}
File Upload Pattern
import {
Controller,
Post,
UseInterceptors,
UploadedFile,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
@Controller("upload")
export class UploadController {
// Single file upload
@Post("single")
@UseInterceptors(FileInterceptor("file"))
uploadFile(@UploadedFile() file: Express.Multer.File) {
return {
filename: file.filename,
originalname: file.originalname,
size: file.size,
mimetype: file.mimetype,
};
}
// Multiple files upload
@Post("multiple")
@UseInterceptors(FilesInterceptor("files", 10))
uploadFiles(@UploadedFiles() files: Express.Multer.File[]) {
return {
count: files.length,
files: files.map((f) => ({
filename: f.filename,
size: f.size,
})),
};
}
// File upload with additional data
@Post("with-data")
@UseInterceptors(FileInterceptor("file"))
uploadWithData(@UploadedFile() file: Express.Multer.File, @Body() data: any) {
return {
file: file.filename,
data,
};
}
}
// Usage:
// curl -X POST http://localhost:3000/upload/single \
// -F "file=@/path/to/file.jpg"
API Versioning Pattern
// Version 1
@Controller({ version: "1", path: "users" })
export class UsersV1Controller {
@Get()
findAll() {
return { version: 1, users: [] };
}
}
// Version 2 with breaking changes
@Controller({ version: "2", path: "users" })
export class UsersV2Controller {
@Get()
findAll() {
return {
version: 2,
data: [],
meta: { total: 0 },
};
}
}
// Enable versioning in main.ts:
app.enableVersioning({
type: VersioningType.URI,
});
// Access:
// GET /v1/users
// GET /v2/users
Search and Sort Pattern
@Controller("articles")
export class ArticlesController {
@Get("search")
async search(
@Query("q") query: string,
@Query("fields") fields?: string,
@Query("sort") sort?: string,
@Query("page") page: string = "1",
@Query("limit") limit: string = "10"
) {
if (!query) {
throw new BadRequestException("Search query is required");
}
const searchFields = fields
? fields.split(",")
: ["title", "content", "author"];
const sortOptions = sort
? this.parseSortOptions(sort)
: [{ field: "relevance", order: "DESC" }];
return this.articlesService.search({
query,
fields: searchFields,
sort: sortOptions,
page: parseInt(page),
limit: parseInt(limit),
});
}
private parseSortOptions(sort: string) {
// Parse: "title:asc,date:desc"
return sort.split(",").map((s) => {
const [field, order] = s.split(":");
return { field, order: order?.toUpperCase() || "ASC" };
});
}
}
// Usage:
// GET /articles/search?q=nestjs&fields=title,content&sort=date:desc,title:asc&page=1&limit=20
Performance Implications
Controller Performance Considerations
Request/Response Overhead:
// ❌ Inefficient: Processing in controller
@Get()
async findAll() {
const users = await this.usersService.findAll(); // 1000 users
// Processing each user in controller
return users.map(user => ({
...user,
fullName: `${user.firstName} ${user.lastName}`,
age: this.calculateAge(user.birthDate)
}));
}
// ✅ Efficient: Processing in service/database
@Get()
findAll() {
// Service returns processed data
return this.usersService.findAllWithCalculations();
}
Memory Usage with Large Responses:
// ❌ Memory intensive: Loading everything
@Get()
async findAll() {
// Loads 100,000 records into memory
return this.usersService.findAll();
}
// ✅ Memory efficient: Pagination
@Get()
async findAll(
@Query('page') page: string = '1',
@Query('limit') limit: string = '50'
) {
// Loads only 50 records
return this.usersService.paginate(
parseInt(page),
parseInt(limit)
);
}
// ✅ Even better: Streaming for large datasets
@Get('export')
async exportAll(@Res() response: Response) {
response.setHeader('Content-Type', 'text/csv');
const stream = await this.usersService.getStream();
stream.pipe(response);
}
Async Performance:
// ❌ Slow: Sequential operations
@Get(':id')
async getDetails(@Param('id') id: string) {
const user = await this.usersService.findOne(id); // 100ms
const posts = await this.postsService.findByUser(id); // 150ms
const stats = await this.statsService.calculate(id); // 200ms
return { user, posts, stats }; // Total: 450ms
}
// ✅ Fast: Parallel operations
@Get(':id')
async getDetails(@Param('id') id: string) {
const [user, posts, stats] = await Promise.all([
this.usersService.findOne(id),
this.postsService.findByUser(id),
this.statsService.calculate(id)
]);
return { user, posts, stats }; // Total: 200ms (slowest operation)
}
Caching Strategies:
@Controller("products")
export class ProductsController {
// Cache expensive operations
@Get("featured")
@Header("Cache-Control", "public, max-age=300") // Cache for 5 minutes
async getFeatured() {
return this.productsService.findFeatured();
}
// No cache for dynamic data
@Get("cart")
@Header("Cache-Control", "no-store, no-cache, must-revalidate")
async getCart(@Headers("authorization") auth: string) {
const userId = this.authService.getUserId(auth);
return this.cartService.findByUser(userId);
}
}
Summary: Key Takeaways
🎯 Controller Fundamentals:
- Controllers handle HTTP requests and return responses
- Use
@Controller()decorator to define base route - Keep controllers thin - delegate to services
- Controllers route requests, services contain logic
🔧 HTTP Methods:
@Get()- Retrieve data (idempotent, safe)@Post()- Create new resources (201 status)@Put()- Replace entire resource (all fields required)@Patch()- Partial update (only changed fields)@Delete()- Remove resources (204 status)
📥 Data Extraction:
@Param('id')- URL parameters (/users/:id)@Query('page')- Query strings (?page=2)@Body()- Request body (POST/PUT/PATCH)@Headers('auth')- HTTP headers- All params and queries are strings - convert manually
🎯 Routing Best Practices:
- Static routes before dynamic routes
- Specific routes before generic routes
- Catch-all routes absolutely last
- Route order matters - first match wins
⚡ Async Operations:
- Use
async/awaitfor database and API calls - Return promises directly when no processing needed
- Run independent operations in parallel with
Promise.all() - Handle errors with try/catch or let NestJS handle them
📊 Status Codes:
- 200 OK - Success
- 201 Created - Resource created
- 204 No Content - Success with no response
- 400 Bad Request - Invalid input
- 401 Unauthorized - Auth required
- 403 Forbidden - Not allowed
- 404 Not Found - Resource doesn't exist
- 500 Internal Error - Server error
⚠️ Common Pitfalls:
- ❌ Don't put business logic in controllers
- ❌ Don't forget params are strings
- ❌ Don't use @Res() unless necessary
- ❌ Don't ignore route order
- ❌ Don't forget to register controllers in modules
✅ Best Practices:
- ✅ Keep controllers thin and focused
- ✅ Use appropriate HTTP methods and status codes
- ✅ Validate input at controller level
- ✅ Handle errors with exception classes
- ✅ Use async/await for I/O operations
- ✅ Return data directly - let NestJS serialize
- ✅ Use pagination for large datasets
- ✅ Document your APIs clearly
What's Next?
Practical Exercises:
- Build a complete REST API for a todo app
- Create a blog API with posts, comments, and users
- Implement search, filtering, and pagination
- Add error handling and proper status codes
Advanced Topics Coming:
- Request validation with pipes
- Authentication and authorization with guards
- Response transformation with interceptors
- Error handling with filters
- File uploads and multipart data
Ready to learn how services work and why dependency injection is so powerful? Let's continue to the Providers article!
Version Information
Tested with:
- NestJS: v10.x
- Node.js: v18.x, v20.x, v22.x
- TypeScript: v5.x
HTTP Methods Support:
- Express (default): All methods supported
- Fastify (optional): All methods supported
Browser Compatibility:
- All modern browsers support all HTTP methods
- Older browsers may require polyfills for PATCH
Known Issues:
- ⚠️ Route order is critical - specific before dynamic
- ⚠️ @Res() disables automatic serialization
- ⚠️ Params/queries are always strings
- ⚠️ Large responses should use streaming or pagination