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 form
nest g itc logging                  # short alias → src/logging/logging.interceptor.ts
nest g itc logging --flat           # no wrapping folder → src/logging.interceptor.ts
nest g itc common/audit             # nested path → src/common/audit/audit.interceptor.ts
nest g itc common/audit --flat      # nested + flat → src/common/audit.interceptor.ts
nest g itc logging --no-spec        # skip the *.spec.ts test file
nest 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 call next.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(…)).

Why an interceptor, not middleware / a pipe / a filter

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:

MethodReturns
getHandler()The handler Function about to run — key for Reflector metadata lookup
getClass()The controller Type (the class, not an instance)
switchToHttp()HttpArgumentsHostgetRequest(), getResponse(), getNext()
switchToRpc()RPC context (microservices)
switchToWs()WebSocket context
getType()'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.

InterceptorPackagePurpose
ClassSerializerInterceptor@nestjs/commonRuns class-transformer’s instanceToPlain on the response. Honors @Exclude(), @Expose(), @Transform(), and groups set via @SerializeOptions(). See the serialization recipe

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

ScopeHow
Globalapp.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.

The global-scope variant of the same DI question — useGlobalInterceptors(new X()) vs APP_INTERCEPTOR — has its own dedicated note: Global pipes, guards, interceptors, and filters via DI. See in particular the worked example of an interceptor that reads config and the side-by-side comparison of useGlobalInterceptors vs APP_INTERCEPTOR.

Order: the FILO trick

The same wrap-around shape applies across multiple interceptors.

  • Inbound (pre code, before next.handle()): global → controller → route.
  • Outbound (RxJS operators, after the handler emits): route → controller → global. First in, last out.
┌─ Global ────────────────────────────────────┐
│  pre                                        │
│  ┌─ Controller ──────────────────────────┐  │
│  │  pre                                  │  │
│  │  ┌─ Route ────────────────────────┐   │  │
│  │  │  pre                           │   │  │
│  │  │      → handler() emits →       │   │  │
│  │  │  post                          │   │  │
│  │  └────────────────────────────────┘   │  │
│  │  post                                 │  │
│  └───────────────────────────────────────┘  │
│  post                                       │
└─────────────────────────────────────────────┘

Each layer wraps the next, so a global logging interceptor sees the final response shape after every controller/route interceptor has transformed it.

RxJS toolbox

The post-phase operators you’ll actually reach for. Imports come from rxjs or rxjs/operators.

OperatorUse case
tap(fn)Side effects (logs, metrics) without changing the value. See the async pre-phase recipe
map(fn)Transform the emitted value (e.g., wrap as { data }). See the wrap-response recipe
catchError(fn)Map exceptions thrown by the handler to a different error. See the map-errors recipe
timeout(ms)Cancel the request after ms and emit a TimeoutError. See the per-route timeout recipe
of(value)Build a stream from a constant — used to short-circuit (cache). See the cache recipe
from(promise)Convert a promise into an observable inside the pre phase
retry({...})Resubscribe on error with count, delay, and predicate options. See the retry recipe
defer(fn)Wrap pre-phase work so its errors land in the stream’s catchError

Common recipes

Gotchas

Common errors

SymptomLikely cause
Response transform doesn’t applyHandler uses @Res() without { passthrough: true }, so Nest never sees the return value
@Exclude() fields appear in the responseHandler returned a plain object, not a class instance — ClassSerializerInterceptor only acts on instances
Global interceptor can’t injectRegistered via useGlobalInterceptors(new X()) instead of APP_INTERCEPTOR provider
catchError doesn’t fireError thrown in pre phase (before next.handle()); only next.handle().pipe(catchError(…)) catches handler errors
Handler runs twicenext.handle() called more than once (often catchError(() => next.handle()) retry attempt) — use the retry operator instead
Logger fires twice for one requestSame interceptor bound at multiple scopes (e.g., globally and at controller level)
Outbound order surprisesOutbound 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 missingStream is hot/multi-subscribed elsewhere — use share() or rethink the pipeline
cannot read property of undefined in interceptorswitchToHttp() 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.

See also