On this page

Interceptors, middleware, and exception filters

12 min read TextCh. 4 — Security and Communication

Three cross-cutting concerns

NestJS provides three mechanisms for logic that applies across many routes without being tied to any specific controller:

Mechanism When it runs Access to response
Middleware Before guards and interceptors Partial (via Express/Fastify)
Interceptors Before AND after route handler Full (wraps the handler)
Exception filters When an exception is thrown Full (constructs the response)

Understanding when to use each is key to keeping your controllers clean.

Middleware

Middleware in NestJS is equivalent to Express middleware — a function that receives the request, response, and next function. It runs before guards, interceptors, and route handlers.

Creating middleware

import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction): void {
    req['id'] = crypto.randomUUID();
    res.setHeader('X-Request-Id', req['id'] as string);
    next();
  }
}

Applying middleware

Middleware is applied in the module's configure method:

import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common';

@Module({})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer): void {
    consumer
      .apply(RequestIdMiddleware)
      .forRoutes('*'); // All routes

    consumer
      .apply(AuthMiddleware)
      .exclude({ path: 'auth/(.*)', method: RequestMethod.ALL })
      .forRoutes(BooksController); // Specific controller
  }
}

Functional middleware

Simple middleware can be written as a plain function:

export function corsMiddleware(req: Request, res: Response, next: NextFunction): void {
  res.setHeader('Access-Control-Allow-Origin', '*');
  next();
}

Interceptors

Interceptors are one of NestJS's most powerful features. They wrap the route handler execution and can:

  • Add logic before the handler runs
  • Add logic after the handler runs and modify the response
  • Transform the value returned by the handler
  • Transform exceptions thrown by the handler
  • Override the handler entirely (caching)

The NestInterceptor interface

Every interceptor implements intercept(context, next):

intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
  // Before the handler
  console.log('Before handler...');

  return next.handle().pipe(
    // After the handler (response transformation)
    map((data) => ({ success: true, data })),
  );
}

next.handle() returns an RxJS Observable. Everything piped after .handle() transforms the response.

Caching interceptor

@Injectable()
export class CacheInterceptor implements NestInterceptor {
  private readonly cache = new Map<string, { data: unknown; expiresAt: number }>();

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const request = context.switchToHttp().getRequest<Request>();
    const key = request.url;
    const cached = this.cache.get(key);

    if (cached && cached.expiresAt > Date.now()) {
      return of(cached.data); // Return cached response immediately
    }

    return next.handle().pipe(
      tap((data) => {
        this.cache.set(key, { data, expiresAt: Date.now() + 60_000 });
      }),
    );
  }
}

Applying interceptors

// Method level
@Get()
@UseInterceptors(CacheInterceptor)
findAll() { ... }

// Controller level
@Controller('books')
@UseInterceptors(LoggingInterceptor)
export class BooksController { ... }

// Global level (in main.ts or via APP_INTERCEPTOR in AppModule)
app.useGlobalInterceptors(new LoggingInterceptor());

Exception filters

When an exception is thrown anywhere in the request pipeline, NestJS looks for an exception filter that handles it. The default behavior is the built-in HttpException handling, which sends a JSON response with the status code and message.

Creating a custom exception filter

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
  catch(exception: HttpException, host: ArgumentsHost): void {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus();

    response.status(status).json({
      statusCode: status,
      message: exception.message,
      timestamp: new Date().toISOString(),
      path: request.url,
    });
  }
}

Catch-all exception filter

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost): void {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();

    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    response.status(status).json({
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
    });
  }
}

Custom exceptions

Extend HttpException for domain-specific errors:

export class BookNotAvailableException extends HttpException {
  constructor(bookId: number) {
    super(
      { message: `Book #${bookId} is not available for checkout`, bookId },
      HttpStatus.CONFLICT,
    );
  }
}

Rate limiting middleware

Install and configure @nestjs/throttler for rate limiting:

npm install @nestjs/throttler
// app.module.ts
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';

@Module({
  imports: [
    ThrottlerModule.forRoot([
      { ttl: 60000, limit: 100 }, // 100 requests per minute
    ]),
  ],
  providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class AppModule {}

// Override per controller/method
@Throttle({ default: { ttl: 60000, limit: 10 } })
@Post('login')
login() { ... }

@SkipThrottle()
@Get('health')
healthCheck() { ... }
Interceptor vs middleware
Use middleware for tasks that do not need access to NestJS metadata (body parsing, CORS, rate limiting). Use interceptors when you need the execution context, typed access to the handler, or the ability to transform responses after the route handler executes.
Exception filter catch order
Nest tries exception filters from most specific to most general. A filter decorated with @Catch(NotFoundException) catches only NotFoundException errors. A filter decorated with @Catch() (no arguments) catches everything. Register specific filters before the catch-all filter.
import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
  Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Request } from 'express';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  private readonly logger = new Logger(LoggingInterceptor.name);

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const request = context.switchToHttp().getRequest<Request>();
    const { method, url } = request;
    const start = Date.now();

    return next.handle().pipe(
      tap({
        next: () => {
          const duration = Date.now() - start;
          this.logger.log(`${method} ${url} → 200 [${duration}ms]`);
        },
        error: (err: unknown) => {
          const duration = Date.now() - start;
          const status = (err as { status?: number }).status ?? 500;
          this.logger.warn(`${method} ${url} → ${status} [${duration}ms]`);
        },
      }),
    );
  }
}