Hey there! As a Full-Stack Software Engineer with over three years of building web applications, I have worked with a lot of code. I have built many backend tools using NestJS, and one of the first things you must learn is how to use controllers. Think of them as the front door to your application. They are very important for handling every request that comes in from the internet.
I want to share what I have learned from real project deadlines, late-night bug hunts, and many code reviews with my team. In this guide, we will look at how controllers handle traffic and share general optimization tips to keep your setup clean, fast, and easy to manage. My goal is to explain controllers in a very simple way, even if you are just starting out, while giving you the practical tips you only get from building real software.
What Are NestJS Controllers?
NestJS controllers are classes that handle incoming HTTP requests and send back responses to the user. They act as the main entry point for web traffic, mapping specific URL paths to your backend code.
When I first started using NestJS, I thought of controllers as traffic police. Imagine someone types a web address into their browser or clicks a button on your website. That action sends a request to your server, and that request hits a NestJS controller first. The controller looks at the request, figures out what the user wants, and then sends it to the right place to get the work done. Once the work is finished, the controller sends a response back, like a success message or the data the user asked for.
Understanding controllers is key to learning the basics of NestJS. Controllers handle the routing of your web addresses, keeping your code organized and easy to read. In our team, we always make sure controllers are clean because they are the entry points. When they are easy to read, finding and fixing bugs becomes much faster.
How NestJS Controllers Work Behind the Scenes
NestJS controllers use JavaScript classes and decorators to listen for and handle incoming web requests. This approach links web addresses directly to specific functions inside your code.
NestJS uses classes and special labels called decorators to handle requests. It is a very clean system. You tell NestJS which web address path your controller should listen to. Whenever a request comes in for that path, NestJS automatically runs the code you wrote in that controller. Last month, while reviewing code with a teammate, we talked about how much easier this is compared to older ways of routing web requests. With NestJS, everything related to a specific set of web addresses is grouped neatly in one class.
It is not magic. NestJS has a powerful core that looks at decorators, which are the words starting with the @ symbol that you put above classes and functions. These decorators act like instructions that tell NestJS, "Hey, this class is a controller, and this function should run when someone visits this specific URL." This clear structure is a huge win for managing bigger applications as they grow.
Creating Your First NestJS Controller: A Practical Guide
You can create a new NestJS controller by using the command-line interface (CLI) with the command nest g controller [name]. This command automatically sets up the folder, file, and basic class structure for you.
To create a controller, you can use the NestJS CLI. It is a great tool that saves a lot of time and prevents manual typing mistakes. Open your terminal and type this command:
nest g controller users
This command is short for "generate controller users". It creates a new file named users.controller.ts inside a users folder. It also updates your main application module so NestJS knows the new controller exists.
When you open the new users.controller.ts file, you will see code like this:
// users.controller.ts
import { Controller, Get } from '@nestjs/common';
@Controller('users')
export class UsersController {
@Get()
findAll(): string {
return 'This action returns all users';
}
}
This is a standard TypeScript class, but notice the @Controller('users') part at the top. This decorator tells NestJS that this class is a controller, and all the web addresses it handles will start with /users. This is the standard way we write code in our projects to keep everything consistent and easy to navigate.
Understanding the `@Controller()` Decorator: The Base of Your Routes
The `@Controller()` decorator marks a class as a controller in NestJS and sets the base path for all routes inside that class. It helps group related web endpoints together under a single URL prefix.
The @Controller() decorator is how you tell NestJS that a class is meant to handle web requests. You can pass a path string to it, like @Controller('users'). This means every route defined inside this UsersController class will automatically start with /users. For example, if you have a function inside that class marked with @Get(), it will respond to GET /users. If you have @Get(':id'), it will respond to GET /users/123.
This path prefix is a smart way to group related logic. Imagine you are building a big application with sections for users, products, and orders. Without the @Controller() decorator, you would have to write /users, /products, or /orders on every single route. With it, NestJS handles that for you, which makes your code cleaner and prevents typos. During a recent project, we had to add a new version of our API. By simply changing the path in the decorator from 'users' to 'v2/users', we quickly set up new endpoints without breaking the old ones.
Handling HTTP Methods with NestJS Decorators
NestJS offers built-in decorators like `@Get()`, `@Post()`, `@Put()`, `@Patch()`, and `@Delete()` to connect HTTP methods to your controller functions. This makes it easy to define how your API reads, creates, updates, and deletes data.
HTTP methods are how web browsers and other apps tell your server what action they want to take. NestJS gives you clear decorators for every common HTTP method, which makes your API code easy to understand. Here is how we use them in our projects:
-
`@Get()`: Used to fetch data. This is for when you want to get information from the server without changing anything. It is like asking for a document.
Real-world use: Getting a list of products, fetching a user's profile, or loading a blog post. When you visit a website, your browser usually makes a GET request.
// Inside UsersController @Get() findAllUsers(): string { return 'This gives you all users.'; } @Get(':id') // e.g., GET /users/123 findUserById(@Param('id') id: string): string { return `This gives you user with ID: ${id}.`; } -
`@Post()`: Used to send new data. This is for creating new records. It is like submitting a signup form.
Real-world use: Registering a new user, submitting an order, or adding an item to a shopping cart.
// Inside UsersController @Post() createNewUser(@Body() createUserDto: any): string { return `Creating a new user with data: ${JSON.stringify(createUserDto)}`; } -
`@Put()`: Used to replace an existing record. If you want to update a resource and send the whole new version of that resource, use PUT.
Real-world use: Updating a user's entire profile where you send all the profile fields, replacing the old version completely.
// Inside UsersController @Put(':id') // e.g., PUT /users/123 updateUserFully(@Param('id') id: string, @Body() updateUserDto: any): string { return `Replacing user ${id} with data: ${JSON.stringify(updateUserDto)}`; } -
`@Patch()`: Used to update only part of a record. This is for partial updates where you only send the fields that changed.
Real-world use: Changing only a user's email address or updating an order status without touching other fields.
// Inside UsersController @Patch(':id') // e.g., PATCH /users/123 updateUserPartially(@Param('id') id: string, @Body() partialUpdateDto: any): string { return `Updating parts of user ${id} with data: ${JSON.stringify(partialUpdateDto)}`; } -
`@Delete()`: Used to remove data.
Real-world use: Deleting a user account, removing an item from a database, or taking down a post.
// Inside UsersController @Delete(':id') // e.g., DELETE /users/123 deleteUser(@Param('id') id: string): string { return `Deleting user with ID: ${id}.`; }
Pro Tip: Always pick the right HTTP method for the job. It makes your API much easier for other developers to use. I once spent an hour fixing a bug where a POST endpoint was used to update an existing database record, causing duplicates. Changing it to PATCH made the API work correctly and clearly.
Getting Data from Requests: Route, Query, Body, and Headers
NestJS provides decorators like `@Param()`, `@Query()`, `@Body()`, and `@Headers()` to help you read data from incoming requests, such as URL parameters, search filters, form data, or authorization keys.
When someone sends a request to your NestJS controllers, they usually send data with it. NestJS makes it very easy to grab this information using specific decorators.
Route Parameters (`@Param()`)
Sometimes you need to find a specific item. If you want to get details for user number 1, the web address might look like /users/1. The 1 in this URL is a route parameter. We use the @Param() decorator to get these values from the URL path.
In our office projects, we use @Param() often for unique identifiers like IDs. For example:
@Get(':id') // Catches URLs like /users/123
findOne(@Param('id') id: string) {
// `id` will be '123'
return `Fetching user with ID: ${id}`;
}
You can also use multiple route parameters, like /products/:categoryId/:productId to get a specific product inside a category. A teammate once showed me how to use @Param() without a key, which gives you all parameters as an object:
@Get(':categoryId/:productId') // Catches URLs like /products/electronics/laptop1
findProduct(@Param() params: { categoryId: string; productId: string }) {
// params will be { categoryId: 'electronics', productId: 'laptop1' }
return `Fetching product ${params.productId} in category ${params.categoryId}`;
}
Query Parameters (`@Query()`)
For search filters, sorting, or splitting results into pages, we use query parameters. These appear in the URL after a question mark, like /users?age=25&city=London. Here, age=25 and city=London are query parameters. We get these using the @Query() decorator.
I find @Query() very helpful for list views where users need to filter data. For example, to get active users over a certain age:
@Get() // Catches URLs like /users?status=active&minAge=30
findFilteredUsers(@Query('status') status: string, @Query('minAge') minAge: string) {
// status will be 'active', minAge will be '30'
return `Fetching users with status: ${status} and minimum age: ${minAge}`;
}
You can also get all query parameters as a single object if you do not specify a key:
@Get() // Catches URLs like /users?page=1&limit=10&sortBy=name
findPaginatedUsers(@Query() query: { page?: string; limit?: string; sortBy?: string }) {
// query will be { page: '1', limit: '10', sortBy: 'name' }
const page = parseInt(query.page || '1');
const limit = parseInt(query.limit || '10');
return `Fetching users: page ${page}, limit ${limit}, sort by ${query.sortBy}`;
}
Request Body (`@Body()`)
When a user sends a form to create a new account or update a profile, that data is sent inside the request body rather than the URL. We use the @Body() decorator to get this data. This is common with POST, PUT, or PATCH requests. The data comes as a JavaScript object.
@Post()
create(@Body() createCatDto: any) {
// `createCatDto` will be the JSON object sent by the user
console.log(createCatDto);
return 'This action adds a new cat';
}
We usually pair @Body() with Data Transfer Objects (DTOs) and validation steps to make sure the incoming data is safe and exactly what we expect. This prevents a lot of headaches later on.
Request Headers (`@Headers()`)
Sometimes you need to check information sent in the headers, like an API key or a security token. The @Headers() decorator lets you grab these values easily.
@Get()
findAll(@Headers('authorization') authorization: string) {
// `authorization` will contain the value of the 'Authorization' header
if (!authorization) {
throw new UnauthorizedException('Authorization header is missing.');
}
return `This action returns all items, authorized by: ${authorization}`;
}
While we typically use Guards for login security, grabbing a specific header for logging or special tracking is where @Headers() comes in handy. It is always important to handle missing headers safely, as shown in the example above.
Raw Request and Response Objects (`@Req()`, `@Res()`)
Sometimes you might need to access the raw Node.js request (@Req()) or response (@Res()) objects. This is useful for low-level tasks, like streaming files or setting custom cookies.
import { Controller, Get, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express'; // Assuming you use Express
@Controller('files')
export class FilesController {
@Get('download/:filename')
downloadFile(@Param('filename') filename: string, @Res() res: Response) {
// Using the raw response object to stream a file directly
res.download(`./uploads/${filename}`);
}
@Get('user-agent')
getUserAgent(@Req() req: Request): string {
// Accessing the raw request object to get the user-agent header
return `Your user agent is: ${req.headers['user-agent']}`;
}
}
Be careful when using @Res(). Using it can bypass some of NestJS's built-in features, like automatic response handling, interceptors, and exception filters. When you use @Res() and send a response manually (such as res.send()), NestJS goes into a custom mode for that route. Try to stick to standard NestJS return values whenever you can, and only use raw objects for very specific low-level tasks. I once used @Res() to stream a large file directly from cloud storage, which worked well because it bypassed standard JSON formatting and saved system memory.
Controllers vs. Services: Keeping Your Code Clean and Smart
In NestJS, controllers should be thin and only handle web requests, while services should contain all the business logic, database queries, and data processing. This separation of concerns keeps your application easy to test and maintain.
A common mistake for beginners is putting too much logic directly into the controller. A controller should have only one main job: receive the request, check the inputs, pass the data to a service, and return the response. Think of the controller like a restaurant host: they greet you, take you to your table, and hand your order to the kitchen. They do not cook the food themselves.
The actual business rules, database queries, and heavy calculations belong in a service (also called a provider). This is often called the "thin controllers, fat services" rule.
Keeping these parts separate is very helpful because it gives you:
- Cleaner Code: Controllers stay small and easy to read, while services focus entirely on data.
- Easier Testing: You can test your service logic on its own without simulating web requests. If your logic is in a service, you can run tests with simple code parameters instead of making fake HTTP calls.
- Better Maintenance: If your database setup changes, you only update the service. If your web address changes, you only update the controller. This keeps your application stable.
- Reusability: A single service can be shared across multiple controllers, which helps you avoid writing duplicate code.
During a recent project, we had to add users from a web signup form and an admin tool. Because we kept our user creation logic in a UsersService instead of a controller, both the public controller and the admin controller could use the same usersService.createUser() function. This kept our code dry and simple. You can read more about how these parts connect in our guide on NestJS architecture.
A Full Real-World NestJS Users Controller Example
This example shows a standard NestJS `UsersController` that handles reading, creating, updating, and deleting users by sending the actual work to a `UsersService`.
Let's look at a complete UsersController. It uses a UsersService to talk to a database. The controller's job is to translate web requests into service calls and return the results. This is a great example of keeping a controller thin and focused.
// src/users/users.controller.ts
import { Controller, Get, Post, Put, Patch, Delete, Param, Body, NotFoundException, HttpStatus, HttpCode } from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Controller('users')
export class UsersController {
// NestJS automatically provides the UsersService here
constructor(private readonly usersService: UsersService) {}
// Handles: GET /users
@Get()
@HttpCode(HttpStatus.OK)
async findAll() {
return this.usersService.findAll();
}
// Handles: GET /users/:id
@Get(':id')
@HttpCode(HttpStatus.OK)
async findOne(@Param('id') id: string) {
const user = await this.usersService.findById(id);
if (!user) {
throw new NotFoundException(`User with ID "${id}" not found.`);
}
return user;
}
// Handles: POST /users
@Post()
@HttpCode(HttpStatus.CREATED)
async create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
// Handles: PUT /users/:id (full update)
@Put(':id')
@HttpCode(HttpStatus.OK)
async updateFull(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
const updatedUser = await this.usersService.updateFull(id, updateUserDto);
if (!updatedUser) {
throw new NotFoundException(`User with ID "${id}" not found.`);
}
return updatedUser;
}
// Handles: PATCH /users/:id (partial update)
@Patch(':id')
@HttpCode(HttpStatus.OK)
async updatePartial(@Param('id') id: string, @Body() partialUpdateDto: Partial<UpdateUserDto>) {
const updatedUser = await this.usersService.updatePartial(id, partialUpdateDto);
if (!updatedUser) {
throw new NotFoundException(`User with ID "${id}" not found.`);
}
return updatedUser;
}
// Handles: DELETE /users/:id
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string) {
const result = await this.usersService.remove(id);
if (!result) {
throw new NotFoundException(`User with ID "${id}" not found.`);
}
}
}
Every function in this controller is short and simple. It takes the request data, calls the service, and returns the results. We also use HttpCode to be clear about our status codes, which is a great practice for clear API communication.
Common NestJS Controller Mistakes and How to Fix Them
To build clean NestJS applications, avoid common controller mistakes like manual service creation, adding business logic directly to controllers, using the wrong HTTP status codes, and skipping input validation.
It is easy to make mistakes when you are starting out or moving fast. Here are some common issues I have seen with NestJS controllers and how to avoid them:
Forgetting to Use Dependency Injection
A common issue is forgetting how NestJS manages services. NestJS uses Dependency Injection to automatically give your controller the services it needs. You must declare services in the constructor:
// The correct way:
constructor(private readonly usersService: UsersService) {}
// The wrong way (do not do this):
// constructor() {
// this.usersService = new UsersService();
// }
If you create a service manually using new UsersService(), you bypass the NestJS management system. This means your service will not get its own dependencies, which can lead to tricky bugs. Last week, a teammate was confused because their service database connection was empty. It turned out they had created the service manually. Adding it to the constructor fixed it instantly.
Too Much Business Logic in the Controller
If your controller functions are very long, you are likely putting logic in the wrong place. Controllers should remain small, passing data to services and returning the results. If a controller function grows past 10 or 15 lines of code, it is a sign that you should move some of that logic into a service instead.
Ignoring HTTP Status Codes
Never ignore your HTTP status codes. Returning a 200 OK status when an error occurs, or a 500 Internal Server Error when a resource simply does not exist, makes your API confusing to use. Always use the correct codes:
200 OK: The request worked.201 Created: A new resource was successfully created.204 No Content: The request worked, but there is no data to send back.400 Bad Request: The input data is invalid.401 Unauthorized: The user needs to log in.403 Forbidden: The user is logged in but does not have permission.404 Not Found: The requested resource does not exist.500 Internal Server Error: A general server error occurred.
Skipping Input Validation
Never trust data sent by users. Without validation, you risk bugs, security issues, and bad database entries. Always use DTOs and validation pipes to check incoming data before it reaches your services. We once had a problem where our API accepted a negative number for an inventory update. A simple check on the input data would have stopped it immediately.
General Optimization and Best Practices for NestJS Controllers
Applying general optimization to your NestJS controllers involves keeping functions short, using Data Transfer Objects (DTOs) for input checks, and handling errors consistently to build a fast, clean API.
To write high-quality NestJS controllers, keep these optimization and design tips in mind:
- Keep Functions Short: Each controller function should have one job. If a function gets complicated, move the complex parts to a service.
- Use DTOs to Validate Data: DTOs are classes that define the shape of incoming data. When you combine them with validation packages, NestJS can automatically check incoming data before your controller code even runs. For example:
// src/users/dto/create-user.dto.ts import { IsString, IsEmail, MinLength } from 'class-validator'; export class CreateUserDto { @IsString() @MinLength(3) name: string; @IsEmail() email: string; }In your controller, you can use it like this:
// src/users/users.controller.ts @Post() async create(@Body() createUserDto: CreateUserDto) { return this.usersService.create(createUserDto); }This setup ensures your controller only receives valid data, which makes your application much more reliable.
- Use Dependency Injection: Always inject services using the constructor to keep your application modular and easy to test.
- Consistent Error Handling: Use NestJS's built-in exceptions to return consistent errors. This makes it much easier for frontend applications to display helpful messages to users.
- Follow Naming Conventions: Stick to standard naming styles (like
UsersControllerandUsersService) to keep your project organized. You can follow the structure patterns found in the NestJS series.
Controllers in the NestJS Request Lifecycle
Before a web request reaches your controller, it goes through a structured pipeline in the NestJS request lifecycle. This pipeline includes guards, interceptors, and pipes that check, log, and clean data.
When a request comes into your NestJS application, it goes on a journey before reaching your controller. Knowing this path helps you decide where to put your code:
- Guards: These act like security checkers. They see if the user is allowed to access the route (for example, "Is this user logged in?"). If they say no, the request stops.
- Interceptors (Before): These can run code before your controller runs, like logging the request or tracking performance times.
- Pipes: These check and convert your data (such as turning a string ID into a number) before passing it to your controller.
- Controller Method: Your controller function runs, processes the request, and asks a service to do the database work.
- Interceptors (After): These run after your controller returns a result, allowing you to clean or adjust the final response.
- Exception Filters: If an error happens anywhere along the way, these filters catch it and send a clean error response back to the user.
NestJS Controllers vs. Express Routes: Why the NestJS Way is Better
NestJS controllers offer a clean, class-based structure that is much easier to scale, organize, and test compared to traditional, flat Express routes.
If you have used Express.js before, you are likely used to writing routes in flat files like this:
// Express example
const express = require('express');
const app = express();
app.get('/users', (req, res) => {
res.send('All users');
});
While this is simple for small projects, it can get very disorganized as your application grows. NestJS controllers solve this by providing:
- Better Organization: All routes for a specific resource are grouped together inside a single controller class.
- Clearer Code: Decorators make it easy to see exactly what each function does. There is no guessing.
- Built-in Tools: Features like security guards, validation pipes, and data interceptors work smoothly with controllers out of the box.
- Easier Testing: Because controllers are standard classes, you can easily create test instances and use fake services to check your code quickly.
Testing Your NestJS Controllers
Testing your controllers ensures that your URL routes are correctly set up and pass data to your services without errors. You can use standard testing tools to mock your services and test your controllers in isolation.
Testing your controllers is highly recommended to make sure your endpoints work. Here is how you can write a unit test for a controller by using a mock service:
// src/users/users.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { NotFoundException } from '@nestjs/common';
describe('UsersController', () => {
let controller: UsersController;
let service: UsersService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UsersController],
providers: [
{
provide: UsersService,
useValue: {
findAll: jest.fn(),
findById: jest.fn(),
},
},
],
}).compile();
controller = module.get<UsersController>(UsersController);
service = module.get<UsersService>(UsersService);
});
it('should return a user if found', async () => {
const result = { id: '1', name: 'Alex', email: 'alex@example.com' };
jest.spyOn(service, 'findById').mockResolvedValue(result);
expect(await controller.findOne('1')).toEqual(result);
expect(service.findById).toHaveBeenCalledWith('1');
});
it('should throw NotFoundException if user is missing', async () => {
jest.spyOn(service, 'findById').mockResolvedValue(null);
await expect(controller.findOne('999')).rejects.toThrow(NotFoundException);
});
});
This test lets us check if our controller correctly calls our service and handles missing data. It is a solid way to make sure updates do not break your existing features.
Production Considerations for NestJS Controllers
When running your NestJS controllers in production, focus on input security, API rate limiting, organized error logging, and performance tweaks like response caching.
Before launching your API live, make sure to check these areas:
Input Checks
Always use validation pipes with your DTOs. This is your first wall of defense against bad data or malicious code attempts.
Security Tools
- CORS: Configure Cross-Origin Resource Sharing so only trusted websites can talk to your API.
- Helmet: Use Helmet to automatically set secure HTTP headers, which protects your app from common web vulnerabilities.
- Rate Limiting: Use rate-limiting tools to limit how many requests a single IP address can make. This protects your server from overload.
Proper Logging and Caching
Use a structured logger to track errors, and use response caching for data that does not change often. Caching static data reduces the load on your database and speeds up response times for your users.
Frequently Asked Questions About NestJS Controllers
Can I have multiple controllers in my application?
Yes. You should create separate controllers for different resources (like UsersController, ProductsController, and OrdersController) to keep your project clean and organized.
Is it okay to use `@Res()` in a NestJS controller?
Only if you have a specific task that NestJS cannot handle on its own, like streaming a file directly. Otherwise, let NestJS handle responses automatically so you do not miss out on built-in features like interceptors.
What is the difference between `@Param()`, `@Query()`, and `@Body()`?
@Param()reads values directly from the URL path (like/users/123).@Query()reads search filters and options from the end of the URL (like/users?status=active).@Body()reads JSON data sent inside the request body (usually with POST or PUT requests).
Should I put business logic in my controller?
No. Keep your controllers thin. Put all your business rules, calculations, and database calls inside services.
Key Takeaways for Working with NestJS Controllers
- Controllers are the entry points: They handle incoming web requests and route them to the right place.
- Keep them thin: Put your business logic in services, and let controllers focus entirely on request handling.
- Use the right decorators: Use
@Get(),@Post(),@Param(), and@Body()to make your routing clear. - Validate inputs: Always use DTOs and validation pipes to keep your application secure.
- Test your code: Use unit tests to make sure your API endpoints behave correctly.
Next Steps
Learning how to use NestJS controllers is the first step toward building great web backends. By keeping your controllers small, organized, and focused on routing, your projects will be much easier to grow, maintain, and debug over time.
To learn more about how to set up modules and connect your application parts, check out the official NestJS documentation. Start with simple routes, use the CLI, and keep your business logic in your services. Happy coding!