NestJS: Middleware vd Interceptor and How to Use Them?
When developing a backend with NestJS, it can be confusing to decide when to use middleware and when to use interceptors. Both features allow you to insert logic between a request and a response, but they differ in timing and capabilities.
In this post, we’ll clarify the differences between middleware and interceptors based on NestJS’s request lifecycle, and examine which to use in real-world scenarios.
Understanding the NestJS Request Lifecycle
The process from receiving a request to sending a response in NestJS is called the request lifecycle. Understanding this flow makes it clear where middleware and interceptors are executed.
Request Lifecycle Order
Looking at this flow, middleware runs at the very beginning, while interceptors run before and after the controller. This distinction defines their respective use cases.
What is Middleware?
Middleware is a function that is called before the route handler is executed. It’s the same concept as Express middleware and can access request, response, and next().
Key Features of Middleware
- Execute arbitrary code
- Modify the request and response objects
- End the request-response cycle
- Call the next middleware
How to Implement Middleware
There are two ways to implement middleware: class-based and functional.
Class-based middleware:
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {console.log(`[${req.method}] ${req.url}`);next();
}
}
Functional middleware:
export function logger(req: Request, res: Response, next: NextFunction) {
console.log(`[${req.method}] ${req.url}`);
next();
}
Applying Middleware
Use the module’s configure() method to apply middleware:
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
@Module({})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {consumer .apply(LoggerMiddleware) .forRoutes('users'); // Apply to specific routes only
}
}
To exclude certain routes, use exclude():
consumer
.apply(LoggerMiddleware)
.exclude({ path: 'users', method: RequestMethod.GET },'users/(.*)',
)
.forRoutes(UsersController);
To apply middleware globally, configure it in main.ts:
const app = await NestFactory.create(AppModule);
app.use(logger);
What is an Interceptor?
An interceptor is a class that uses the @Injectable() decorator and implements the NestInterceptor interface. It’s inspired by Aspect-Oriented Programming (AOP).
Powerful Features of Interceptors
- Bind extra logic before and after method execution
- Transform results returned from a function
- Transform exceptions thrown by functions
- Extend base function behavior
- Completely override functions based on conditions (e.g., caching)
How to Implement Interceptors
All interceptors must implement the intercept() method:
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {console.log('Before...');
const now = Date.now();return next .handle() .pipe( tap(() => console.log(`After... ${Date.now() - now}ms`)), );
}
}
intercept() takes two arguments:
ExecutionContext: Information about the current execution contextCallHandler: Calls the route handler using thehandle()method
Applying Interceptors
Use the @UseInterceptors() decorator:
@UseInterceptors(LoggingInterceptor)
export class UsersController {}
To apply globally:
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new LoggingInterceptor());
Transforming Responses with RxJS
One of the true powers of interceptors is the ability to use RxJS operators:
@Injectable()
export class TransformInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {return next.handle().pipe( map(data => ({ data, timestamp: new Date().toISOString() })));
}
}
They can also handle exceptions:
@Injectable()
export class ErrorsInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {return next .handle() .pipe( catchError(err => throwError(() => new BadGatewayException())), );
}
}
Middleware vs Interceptor: Key Differences
Execution Timing
Middleware
- Runs at the very beginning of the request lifecycle
- Executes before guards, interceptors, and pipes
- Runs sequentially (global → module-bound)
Interceptor
- Runs before and after the controller
- Executes after guards, before pipes (pre-controller)
- On response, runs in reverse order (route → controller → global)
Functional Differences
Middleware
- Directly accesses request and response objects
- Passes control using
next() - Can end the response cycle
- Cannot use RxJS
Interceptor
- Provides richer context with
ExecutionContext - Operates on RxJS Observable
- Can transform response data and handle exceptions
- Manages logic before and after route handler in one place
Scope of Use
Middleware
- Only usable in HTTP layer
- Not available in WebSocket or Microservices
- Applied only by route/path
Interceptor
- Usable in all types of app contexts
- Supports HTTP, WebSocket, and Microservices
- Can be applied at controller, method, or global level
Real-World Usage: When to Use What?
Use Middleware When:
1. Logging Requests
export function requestLogger(req: Request, res: Response, next: NextFunction) {
console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
next();
}
2. CORS Settings
export function cors(req: Request, res: Response, next: NextFunction) {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE');
next();
}
3. Body Parsing (if you need custom parsing)
export function customBodyParser(req: Request, res: Response, next: NextFunction) {
// Handle custom body format
next();
}
4. Session Management
@Injectable()
export class SessionMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {// Initialize and verify sessionnext();
}
}
Use Interceptors When:
1. Unified Response Format
@Injectable()
export class ResponseInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {return next.handle().pipe( map(data => ({ statusCode: context.switchToHttp().getResponse().statusCode, data, timestamp: new Date().toISOString(), })),);
}
}
2. Error Transformation
@Injectable()
export class ErrorInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {return next.handle().pipe( catchError(error => { if (error instanceof EntityNotFoundError) { throw new NotFoundException('Resource not found'); } throw error; }),);
}
}
3. Caching
@Injectable()
export class CacheInterceptor implements NestInterceptor {
constructor(private cacheManager: Cache) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {const key = context.switchToHttp().getRequest().url;const cachedResponse = await this.cacheManager.get(key);
if (cachedResponse) { return of(cachedResponse);}
return next.handle().pipe( tap(response => this.cacheManager.set(key, response)),);
}
}
4. Measuring Execution Time
@Injectable()
export class TimingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {const now = Date.now();const request = context.switchToHttp().getRequest();return next.handle().pipe( tap(() => { const elapsed = Date.now() - now; console.log(`${request.method} ${request.url} - ${elapsed}ms`); }),);
}
}
Pro Tip: Using Both Together
In real projects, it’s common to use middleware and interceptors together:
// Middleware: Verify authentication token
@Injectable()
export class AuthMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {const token = req.headers.authorization;if (!token) { throw new UnauthorizedException();}// After verification, add user info to reqreq.user = verifyToken(token);next();
}
}
// Interceptor: Customize response per user
@Injectable()
export class UserContextInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {const request = context.switchToHttp().getRequest();const user = request.user;
return next.handle().pipe( map(data => ({ ...data, requestedBy: user.id, permissions: user.permissions, })),);
}
}
Conclusion
Middleware and interceptors each have their strengths:
Use Middleware When:
- You need logic to run early in the request lifecycle
- You want to leverage Express features directly
- You want simple, route-based logic
Use Interceptor When:
- You want to transform or manipulate response data
- You want to leverage RxJS’s powerful operators
- You want to manage logic around controller execution in one place
- You need compatibility with WebSockets or Microservices
Combining both appropriately leads to clean, maintainable code. Keeping the request lifecycle in mind and choosing the right tool for each stage is the key!