Skip to content
NestJS ns interceptors 4 min read

Interceptors Overview

An interceptor is a class that can run logic both before and after a route handler executes, and can even transform or replace what the handler returns. Inspired by Aspect-Oriented Programming (AOP), interceptors are NestJS’s tool for cross-cutting concerns — logging, timing, response shaping, caching, and error mapping — that would otherwise be scattered and duplicated across every handler. Because an interceptor sits on both sides of the handler call, it sees the request on the way in and the response stream on the way out, all without the handler knowing it exists.

What an interceptor is

Every interceptor implements the NestInterceptor interface, which requires a single method: intercept. Nest calls this method with two arguments — an ExecutionContext (the same rich context guards receive, exposing the handler, controller, and underlying request) and a CallHandler. The CallHandler is the bridge to the route handler: calling handle() invokes the handler and returns an RxJS Observable of its eventual result.

This is the defining shape of an interceptor: code you write before next.handle() runs before the handler; operators you chain onto the returned stream run after the handler resolves. Interceptors are @Injectable() providers, so they participate fully in dependency injection and can inject services, a Reflector, or configuration.

// logging.interceptor.ts
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 now = Date.now();
    const { method, url } = context.switchToHttp().getRequest();

    // Code here runs BEFORE the handler.
    return next.handle().pipe(
      // Operators here run AFTER the handler emits its result.
      tap(() => console.log(`${method} ${url} +${Date.now() - now}ms`)),
    );
  }
}

Output:

GET /cats +4ms
POST /cats +11ms

CallHandler and the response stream

CallHandler.handle() is the point at which the route handler actually runs. Crucially, the handler does not execute until you call handle(). This gives an interceptor full control: it can short-circuit and never call handle() (returning a cached value instead), it can run setup logic first, or it can defer execution.

Once invoked, handle() returns an Observable<T> that emits the handler’s return value — even when the handler returns a plain object or a Promise, Nest wraps it into an Observable for you. Because the result is a stream, you manipulate it with standard RxJS operators rather than imperative callbacks.

OperatorTypical use in an interceptor
tapSide effects without changing the value — logging, metrics, timing
mapTransform the response payload (e.g. wrap in an envelope)
catchErrorMap or rethrow errors as different exceptions
timeoutAbort handlers that take too long
// transform.interceptor.ts
import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

export interface Envelope<T> {
  data: T;
  statusCode: number;
}

@Injectable()
export class TransformInterceptor<T>
  implements NestInterceptor<T, Envelope<T>>
{
  intercept(
    context: ExecutionContext,
    next: CallHandler<T>,
  ): Observable<Envelope<T>> {
    const statusCode = context.switchToHttp().getResponse().statusCode;
    return next.handle().pipe(map((data) => ({ data, statusCode })));
  }
}

Forgetting to return the stream from next.handle() — or returning it without calling .handle() at all — means the handler never runs and the request hangs. Always return next.handle().pipe(...).

Where interceptors sit in the lifecycle

Interceptors straddle the route handler. The “pre” portion (everything before next.handle()) runs after guards and before pipes; the “post” portion (the piped operators) runs after the handler has produced its value but before the response is sent.

Incoming request
  -> Middleware
  -> Guards
  -> Interceptors (pre)      <- code before next.handle()
  -> Pipes
  -> Route handler
  -> Interceptors (post)     <- operators piped onto the stream
  -> Exception filters
  -> Response

When several interceptors are bound, their pre-handler code runs in registration order (outermost first), and their post-handler operators run in reverse — like a nested set of wrappers around the handler.

Binding an interceptor

Interceptors are applied with @UseInterceptors() at method or controller scope, or registered globally through the APP_INTERCEPTOR token so they stay inside the DI container.

// app.module.ts
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { LoggingInterceptor } from './logging.interceptor';

@Module({
  providers: [{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }],
})
export class AppModule {}

Best Practices

  • Implement NestInterceptor for one concern per interceptor — keep logging, transformation, and caching as separate, composable units.
  • Always return next.handle().pipe(...); never swallow the stream or the handler will never run.
  • Use tap for pure side effects and map for payload transformation — don’t mutate the emitted object in place.
  • Read the request/response through context.switchToHttp() so the interceptor stays transport-agnostic.
  • Register cross-cutting interceptors with APP_INTERCEPTOR so they can inject services and the Reflector.
  • Use catchError and timeout to harden handlers, mapping low-level failures into meaningful HTTP exceptions.
Last updated June 14, 2026
Was this helpful?