NestJS Interceptors and How to Skip Response Wrapping
How to use a global NestJS interceptor to wrap successful API responses, and how to skip wrapping for webhooks, file downloads, and protocol-specific endpoints.
When building APIs, it is common to keep successful responses in a consistent shape. A route handler may return user data:
{
"user": "xxx",
"imageUrl": "https://example.com/avatar.png"
}
The API response may need to wrap that value:
{
"code": 0,
"success": true,
"data": {
"user": "xxx",
"imageUrl": "https://example.com/avatar.png"
}
}
This is a good use case for a NestJS interceptor. Interceptors can run before and after a route handler, and they can use RxJS operators to transform the value returned by the handler.
Chinese version of this article
Wrapping Successful Responses
A basic response wrapping interceptor can look like this:
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
interface ApiResponse<T> {
code: number;
success: true;
data: T;
}
@Injectable()
export class ResponseWrapInterceptor<T>
implements NestInterceptor<T, ApiResponse<T>>
{
intercept(
context: ExecutionContext,
next: CallHandler<T>,
): Observable<ApiResponse<T>> {
return next.handle().pipe(
map((data) => ({
code: 0,
success: true,
data,
})),
);
}
}
The key detail is that next.handle() returns an Observable. The route handler’s value enters that stream, and map() transforms it into the public response shape.
To register it globally while keeping dependency injection available, use APP_INTERCEPTOR:
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ResponseWrapInterceptor } from './response-wrap.interceptor';
@Module({
providers: [
{
provide: APP_INTERCEPTOR,
useClass: ResponseWrapInterceptor,
},
],
})
export class AppModule {}
Most controllers can now return business data directly. They do not need to manually write { code, success, data } for every endpoint.
Use Exception Filters for Error Responses
Some implementations use another interceptor with catchError() to wrap errors. That can work, but it is not always the clearest boundary.
Wrapping successful responses is a transformation after a handler returns normally, which fits an interceptor well. Error responses are exception handling, and NestJS has exception filters for that. Keeping the two paths separate reduces the chance of swallowing exceptions inside a response interceptor.
A simplified HTTP exception filter can look like this:
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message =
exception instanceof HttpException
? exception.message
: 'Internal server error';
response.status(status).json({
code: status,
success: false,
message,
});
}
}
Register it globally in main.ts:
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new HttpExceptionFilter());
await app.listen(3000);
Real projects can add business error codes, request IDs, logging, and custom exception classes. The core boundary stays the same: use an interceptor for successful response mapping, and use a filter for exception responses.
Why Some Endpoints Need to Skip Wrapping
A global interceptor affects every endpoint. Some endpoints should not return a JSON wrapper:
- Webhooks from WeChat, GitHub, Stripe, and similar platforms may require a fixed body or status code.
- File download endpoints need to return streams.
- Images, QR codes, and CSV exports have their own
Content-Type. - Proxy endpoints may need to pass through the upstream response.
If those responses are wrapped as { code, success, data }, the caller may not recognize the protocol response. The solution is to mark endpoints that should skip wrapping, then let the interceptor read that metadata.
Define a Skip Decorator
Use SetMetadata to define a decorator:
import { SetMetadata } from '@nestjs/common';
export const SKIP_RESPONSE_WRAP = 'skipResponseWrap';
export const SkipResponseWrap = () => SetMetadata(SKIP_RESPONSE_WRAP, true);
Then inject Reflector into the interceptor and read metadata from both the route handler and the controller class:
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { SKIP_RESPONSE_WRAP } from './skip-response-wrap.decorator';
interface ApiResponse<T> {
code: number;
success: true;
data: T;
}
@Injectable()
export class ResponseWrapInterceptor<T>
implements NestInterceptor<T, ApiResponse<T> | T>
{
constructor(private readonly reflector: Reflector) {}
intercept(
context: ExecutionContext,
next: CallHandler<T>,
): Observable<ApiResponse<T> | T> {
const skip = this.reflector.getAllAndOverride<boolean>(
SKIP_RESPONSE_WRAP,
[context.getHandler(), context.getClass()],
);
if (skip) {
return next.handle();
}
return next.handle().pipe(
map((data) => ({
code: 0,
success: true,
data,
})),
);
}
}
context.getHandler() refers to the current route method. context.getClass() refers to the controller class. getAllAndOverride() lets the same decorator work at method level or controller level.
Usage
If a WeChat message endpoint must return the plain text success, mark that route with @SkipResponseWrap():
import { Body, Controller, HttpCode, Post } from '@nestjs/common';
import { SkipResponseWrap } from './skip-response-wrap.decorator';
@Controller('wechat')
export class WechatController {
@Post('push')
@HttpCode(200)
@SkipResponseWrap()
async handleWechatPush(@Body() data: unknown) {
// Verify signature, process the message, record logs, and so on.
return 'success';
}
}
This endpoint returns the plain string success, not:
{
"code": 0,
"success": true,
"data": "success"
}
If every endpoint in a controller should skip wrapping, put the decorator on the class:
@SkipResponseWrap()
@Controller('files')
export class FilesController {}
Practical Boundaries
The NestJS documentation includes an important warning: response mapping does not work with the library-specific response strategy, such as directly using the @Res() object in a handler.
A practical split is:
- Normal JSON APIs: return business objects and let the global interceptor wrap them.
- Protocol-specific responses: add
@SkipResponseWrap()and return the exact value required. - File streams or strongly controlled headers: add
@SkipResponseWrap()and use@Res()orStreamableFilewhen needed. - Error responses: prefer exception filters instead of mixing them into the successful response interceptor.
This keeps the global rule simple while still giving special endpoints an explicit escape hatch. The interceptor wraps successful responses, the decorator declares exceptions, and the exception filter handles error shape.