Skip to content

NestJS Interceptors

Interceptors wrap around the entire execution of a route handler, letting you run logic both before and after it runs, and even transform its result. This lesson covers the NestInterceptor interface and RxJS basics needed to use it.

What Is an Interceptor?

An interceptor implements NestInterceptor with an intercept() method that receives the execution context and a CallHandler. Calling next.handle() invokes the route handler and returns an RxJS Observable of its result, which the interceptor can transform, log around, or even replace.

import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const start = Date.now();
    return next.handle().pipe(
      tap(() => console.log(`Request took ${Date.now() - start}ms`)),
    );
  }
}

next.handle() triggers the actual route handler. The tap() operator runs a side effect (logging) once the handler's result is emitted, without changing the value itself.

Applying an Interceptor

@UseInterceptors(LoggingInterceptor)
@Get()
findAll() {}

app.useGlobalInterceptors(new LoggingInterceptor());
  • Interceptors can run logic both before (intercept()'s own code) and after (via RxJS operators on next.handle()) the handler executes.
  • map() transforms the handler's return value before it's sent to the client.
  • tap() runs a side effect without altering the emitted value.
  • Interceptors can also catch errors from the handler using catchError().

Interceptor Cheatsheet

Common RxJS operators used inside interceptors.

Operator Use Case
tap() Logging, side effects, without changing the response
map() Reshaping or wrapping the response payload
catchError() Transforming or logging errors from the handler
timeout() Aborting a request that takes too long

Transforming the Response Shape

A common use case is wrapping every successful response in a consistent envelope, like { data: ..., timestamp: ... }, without repeating that logic in every controller method.

@Injectable()
export class ResponseWrapperInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler) {
    return next.handle().pipe(
      map((data) => ({ data, timestamp: new Date().toISOString() })),
    );
  }
}

Interceptors vs. Guards vs. Pipes

Guards decide whether a request proceeds at all. Pipes transform individual arguments before the handler runs. Interceptors wrap the entire handler execution, running before and after, and can transform the handler's return value, something neither guards nor pipes can do.

Common Mistakes

  • Forgetting to call next.handle(), which prevents the actual route handler from ever running.
  • Mutating the emitted value inside tap() when map() is the operator meant for transformation.
  • Writing response-shaping logic separately in every controller instead of one shared interceptor.
  • Not understanding that interceptor logic before next.handle() runs before the handler, and operators after it run after.

Key Takeaways

  • Interceptors implement NestInterceptor and wrap around a handler's entire execution.
  • next.handle() returns an RxJS Observable of the handler's result to operate on.
  • map(), tap(), and catchError() are the most common operators used inside interceptors.
  • Interceptors are the right tool for logging, response transformation, and caching, not authorization.

Pro Tip

If you find yourself writing the same map() response-wrapping logic in multiple interceptors, extract it into one shared global interceptor instead, registered once via APP_INTERCEPTOR, so every endpoint gets a consistent response shape automatically.