Wrap the route handler with logic that runs before and after it. A single AOP “around” advice — built on RxJS, so the response stream is fair game.
Signature
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common"import { Observable, tap } from "rxjs"@Injectable()export class LoggingInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable<any> { console.log("Before...") // pre const start = Date.now() return next.handle().pipe( tap(() => console.log(`After ${Date.now() - start}ms`)), // post ) }}
NestInterceptor<T, R> is generic: T is the type emitted by the handler (Observable<T>) and R is what your interceptor emits downstream (Observable<R>). Both methods can be async.
Generate with the CLI
nest generate interceptor logging # full formnest g itc logging # short alias → src/logging/logging.interceptor.tsnest g itc logging --flat # no wrapping folder → src/logging.interceptor.tsnest g itc common/audit # nested path → src/common/audit/audit.interceptor.tsnest g itc common/audit --flat # nested + flat → src/common/audit.interceptor.tsnest g itc logging --no-spec # skip the *.spec.ts test filenest g itc logging --dry-run # preview the file plan, write nothing
Creates <name>.interceptor.ts (and <name>.interceptor.spec.ts unless --no-spec). The nest CLI wraps the file in a folder named after the element by default; pass --flat to drop it directly in the target path. Source: @nestjs/cli generate command, Nest CLI usages.
The pre/post pattern
One method, two halves
intercept(context, next) runs once per request. Code before next.handle() is the pre phase; RxJS operators piped onto the returned Observable are the post phase. NestJS calls this the AOP “Pointcut” pattern — the handler invocation is the pointcut, your interceptor wraps it.
intercept(ctx, next) { // ── PRE ─────────────── runs before the handler return next.handle().pipe( // ── POST ───────────── runs after the handler emits );}
If you never callnext.handle(), the handler is skipped — useful for caching (see recipes below). Source: NestJS Interceptors > Call handler.
Pre-phase short-circuit
The “skip the handler” pattern has two shapes:
Return a fresh Observable (e.g. of(cached)) instead of next.handle() → the handler never runs, the post-phase operators don’t run either (you’re returning a different stream).
Throw in the pre phase → the error skips next.handle() and falls through to exception filters. catchError chained on next.handle() does not see it (the error never reached the stream). To recover from a pre-phase throw, wrap the pre logic in try/catch or use defer(() => …).pipe(catchError(…)).
Interceptors are the sandwich: bread (pre-phase) → filling (the handler) → bread (post-phase). They’re the only layer that wraps both sides of the handler with logic that can also see the return value. The other three each cover one slice:
Middleware runs before the rest of the lifecycle, has no ExecutionContext, and can’t read the response. Good for raw-request mutation, useless for response shaping or timing.
Pipes transform a single argument before the handler. They can’t see the response and don’t run on the way out.
Exception filters only run on a thrown error. Mapping the success value or timing the handler is not their job.
If the work has a “before AND after” shape, or it operates on the handler’s return value, it belongs in an interceptor.
ExecutionContext essentials
Same ExecutionContext that guards use. The methods you’ll actually call:
Method
Returns
getHandler()
The handler Function about to run — key for Reflector metadata lookup
'http' | 'rpc' | 'ws' (or 'graphql' with @nestjs/graphql) — branch on this for cross-transport interceptors
Reading route metadata works exactly like in a guard: inject Reflector, call reflector.getAllAndOverride(decorator, [ctx.getHandler(), ctx.getClass()]). See Guards > Reflector and custom decorators for the full pattern.
Built-in interceptors
Nest ships only one out of the box; the rest you compose yourself with RxJS.
Interceptor
Package
Purpose
ClassSerializerInterceptor
@nestjs/common
Runs class-transformer’s instanceToPlain on the response. Honors @Exclude(), @Expose(), @Transform(), and groups set via @SerializeOptions(). See the serialization recipe
Excluding fields from the response
import { ClassSerializerInterceptor, Controller, Get, Param, UseInterceptors,} from "@nestjs/common"import { Exclude } from "class-transformer"export class UserEntity { id: number email: string @Exclude() password: string constructor(partial: Partial<UserEntity>) { Object.assign(this, partial) }}@Controller("users")@UseInterceptors(ClassSerializerInterceptor)export class UsersController { @Get(":id") findOne(@Param("id") id: string): UserEntity { return new UserEntity({ id: +id, email: "a@b.c", password: "secret" }) }}
Response body: { "id": 1, "email": "a@b.c" }; password is stripped. Full coverage in the serialization recipe (groups, @Expose, @Transform, excludeAll).
ClassSerializerInterceptor only acts on class instances
Nest’s ClassSerializerInterceptor delegates to class-transformer’s instanceToPlain. Returning a plain object (return { id, email, password }) bypasses it silently — @Exclude() decorators do nothing. Always return new UserEntity({...}) (or array of instances) when you want serialization to fire. Requires the class-transformer peer dep.
Binding
Scope
How
Global
app.useGlobalInterceptors(new LoggingInterceptor()) or the APP_INTERCEPTOR provider (preferred — supports DI)
Controller
@UseInterceptors(LoggingInterceptor) on the class
Route
@UseInterceptors(LoggingInterceptor) on the method
Controller- and route-scoped bindings always resolve the interceptor through Nest’s DI container (you pass the class, not an instance), so they can inject anything the module exposes. The catch is global scope.
Pass the class, not an instance
@UseInterceptors(LoggingInterceptor) is resolved by Nest’s DI container so the interceptor’s constructor injections are wired up. @UseInterceptors(new LoggingInterceptor()) skips DI: any injected dependency is undefined and the interceptor crashes the first time it touches it. Same trap covered in detail at Guards > Binding.
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common"import { of } from "rxjs"@Injectable()export class CacheInterceptor implements NestInterceptor { private store = new Map<string, unknown>() intercept(ctx: ExecutionContext, next: CallHandler) { const key = ctx.switchToHttp().getRequest<{ url: string }>().url const cached = this.store.get(key) return cached ? of(cached) : next.handle() }}
Returning a fresh Observable (here from of) means next.handle() is never called and the handler doesn’t run. The Map is a stub — swap it for a real cache (@nestjs/cache-manager, Redis) in production.
retry resubscribes the source on error, which re-invokes the handler. Only safe for idempotent operations (GET, deterministic computations). Never wrap mutating endpoints in a blanket retry. Source: rxjs retry.
Async pre phase (returning Promise<Observable>)
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common"import { Observable, tap } from "rxjs"@Injectable()export class AuditInterceptor implements NestInterceptor { async intercept(ctx: ExecutionContext, next: CallHandler): Promise<Observable<unknown>> { await this.recordStart(ctx) // async pre work return next.handle().pipe(tap(() => this.recordEnd(ctx))) } private async recordStart(_ctx: ExecutionContext) {} private async recordEnd(_ctx: ExecutionContext) {}}
Nest awaits the returned promise before subscribing. The handler is delayed until recordStart resolves, so don’t await slow I/O here unless you mean to add latency to every request. Errors thrown from the awaited code skip next.handle() entirely (pre-phase throw — see above).
Gotchas
@Res() disables response mapping
If a handler injects @Res() and writes to the response directly, RxJS operators on the returned stream don’t run — Nest never receives a return value to pipe through. Use @Res({ passthrough: true }) when you need both raw access (cookies, streaming) and interceptors.
Calling next.handle() more than once runs the handler more than once
Each call to next.handle() returns a fresh cold Observable; subscribing twice subscribes the handler twice. The naïve “try cache then fall back” pattern is the usual offender:
// BUG: handler runs even on cache hitintercept(ctx: ExecutionContext, next: CallHandler) { return next.handle().pipe( tap(() => this.maybeStore(ctx)), // someone else later adds: catchError(() => next.handle()) ← second subscription )}
Rule: invoke next.handle() exactly once per request. To replay a value, capture it with tap or shareReplay, don’t re-subscribe. To short-circuit, return a different observable (of(x)) instead of calling next.handle() at all.
Cross-transport interceptors must branch on ctx.getType()
The same interceptor class can be applied to HTTP, microservice, WebSocket, and GraphQL handlers. The request shape and “response” semantics differ in each context — switchToHttp().getResponse() is meaningless in RPC, and the return value of an RPC handler doesn’t become an HTTP body. Branch explicitly:
if (ctx.getType() === "http") { const res = ctx.switchToHttp().getResponse() // …HTTP-only logic}
The pipeline next.handle().pipe(catchError(...)) only catches errors emitted by the handler stream. An error thrown synchronously before next.handle() is invoked is a regular thrown exception — it bypasses RxJS entirely and lands in exception filters. Use try/catch in the pre phase, or wrap the pre work in defer(() => …).
Common errors
Symptom
Likely cause
Response transform doesn’t apply
Handler uses @Res() without { passthrough: true }, so Nest never sees the return value
@Exclude() fields appear in the response
Handler returned a plain object, not a class instance — ClassSerializerInterceptor only acts on instances
Global interceptor can’t inject
Registered via useGlobalInterceptors(new X()) instead of APP_INTERCEPTOR provider
catchError doesn’t fire
Error thrown in pre phase (before next.handle()); only next.handle().pipe(catchError(…)) catches handler errors
Handler runs twice
next.handle() called more than once (often catchError(() => next.handle()) retry attempt) — use the retry operator instead
Logger fires twice for one request
Same interceptor bound at multiple scopes (e.g., globally and at controller level)
Outbound order surprises
Outbound is FILO — route post runs first, global post runs last. Don’t bind the same interceptor at two scopes
tap runs on subscribe but value is missing
Stream is hot/multi-subscribed elsewhere — use share() or rethink the pipeline
cannot read property of undefined in interceptor
switchToHttp() called in non-HTTP context — branch on ctx.getType() for cross-transport interceptors
When to reach for it
Logging, metrics, distributed tracing.
Response shape transforms (wrap every response in { data, meta }).
Caching, retries, timeouts.
Mapping infrastructure errors to HTTP exceptions before filters see them.
When not to
Mutating the raw request, attaching correlation IDs before auth runs: use middleware (interceptors run after guards).
Authorization decisions: use a guard. Throwing from an interceptor works but loses the declarative role/permission shape.
Validating or coercing a single handler argument: use a pipe.
Catching every thrown exception across the app to shape the error response: that’s an exception filter. Interceptors can map errors with catchError, but only for the handler stream.