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.
| Operator | Typical use in an interceptor |
|---|---|
tap | Side effects without changing the value — logging, metrics, timing |
map | Transform the response payload (e.g. wrap in an envelope) |
catchError | Map or rethrow errors as different exceptions |
timeout | Abort 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. Alwaysreturn 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
NestInterceptorfor 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
tapfor pure side effects andmapfor 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_INTERCEPTORso they can inject services and theReflector. - Use
catchErrorandtimeoutto harden handlers, mapping low-level failures into meaningful HTTP exceptions.