Response Transformation
A controller usually returns a bare payload — an entity, an array, a primitive — but the clients consuming your API often want a predictable shape: a consistent envelope with the data plus some metadata. Rewriting every handler to build that envelope by hand is tedious and easy to get wrong. Interceptors solve this elegantly: because they sit on the response side of the request pipeline, a single TransformInterceptor can wrap every handler’s return value in a standard { data, meta } structure using the RxJS map operator. This page builds that interceptor, makes the envelope type-safe, and shows how to skip or vary the transformation per route.
Why transform on the response stream
A NestInterceptor returns an Observable. Whatever your handler returns — a synchronous value, a Promise, or an Observable — Nest normalizes it into a stream and pipes it through next.handle(). That means you can attach RxJS operators after next.handle() to operate on the resolved value just before it is serialized and sent. map is the natural choice: it takes the emitted payload and returns a new one, leaving the rest of the pipeline untouched.
This is strictly better than mutating the Response object directly. The interceptor stays transport-agnostic, composes with other interceptors, and never has to know whether the controller used a Promise or an Observable.
A basic transform interceptor
Start with a generic interceptor that wraps any payload in a { data, meta } envelope. The generic T flows through so callers keep their original types.
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Request } from 'express';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
export interface ResponseEnvelope<T> {
data: T;
meta: {
timestamp: string;
path: string;
statusCode: number;
};
}
@Injectable()
export class TransformInterceptor<T>
implements NestInterceptor<T, ResponseEnvelope<T>>
{
intercept(
context: ExecutionContext,
next: CallHandler<T>,
): Observable<ResponseEnvelope<T>> {
const http = context.switchToHttp();
const request = http.getRequest<Request>();
const statusCode = http.getResponse().statusCode;
return next.handle().pipe(
map((data) => ({
data,
meta: {
timestamp: new Date().toISOString(),
path: request.url,
statusCode,
},
})),
);
}
}
The NestInterceptor<T, R> signature documents the input and output types, so the compiler knows the handler emits T and the client receives ResponseEnvelope<T>. The map callback runs once per emitted value, after the handler’s Promise/Observable has resolved.
Binding it globally
Most teams want every route enveloped, so bind the interceptor application-wide with APP_INTERCEPTOR. This keeps it inside the DI container, so it can inject other providers later.
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { TransformInterceptor } from './common/transform.interceptor';
@Module({
providers: [
{
provide: APP_INTERCEPTOR,
useClass: TransformInterceptor,
},
],
})
export class AppModule {}
Given a controller that simply returns an array, the client now receives the wrapped form:
import { Controller, Get } from '@nestjs/common';
@Controller('users')
export class UsersController {
@Get()
findAll() {
return [{ id: 1, name: 'Ada' }];
}
}
Output:
GET /users -> 200
{
"data": [{ "id": 1, "name": "Ada" }],
"meta": {
"timestamp": "2026-06-14T10:22:31.004Z",
"path": "/users",
"statusCode": 200
}
}
Conditionally transforming by route
Not every endpoint should be enveloped. A health check, a file download, or a third-party webhook callback often needs the raw body. Use a metadata decorator to mark routes that should opt out, then read it with Reflector.
import { SetMetadata } from '@nestjs/common';
export const RAW_RESPONSE = 'rawResponse';
export const RawResponse = () => SetMetadata(RAW_RESPONSE, true);
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { RAW_RESPONSE } from './raw-response.decorator';
import { ResponseEnvelope } from './response-envelope';
@Injectable()
export class TransformInterceptor<T>
implements NestInterceptor<T, T | ResponseEnvelope<T>>
{
constructor(private readonly reflector: Reflector) {}
intercept(
context: ExecutionContext,
next: CallHandler<T>,
): Observable<T | ResponseEnvelope<T>> {
const isRaw = this.reflector.getAllAndOverride<boolean>(RAW_RESPONSE, [
context.getHandler(),
context.getClass(),
]);
if (isRaw) return next.handle();
const http = context.switchToHttp();
const request = http.getRequest<Request>();
const statusCode = http.getResponse().statusCode;
return next.handle().pipe(
map((data) => ({
data,
meta: {
timestamp: new Date().toISOString(),
path: request.url,
statusCode,
},
})),
);
}
}
Now any handler annotated with @RawResponse() bypasses the envelope entirely:
import { Controller, Get } from '@nestjs/common';
import { RawResponse } from './common/raw-response.decorator';
@Controller('health')
export class HealthController {
@Get()
@RawResponse()
check() {
return { status: 'ok' };
}
}
Tip: Reading metadata with
getAllAndOverride([getHandler(), getClass()])lets you opt out a single route or an entire controller — handler metadata wins, so@RawResponse()on the class can be re-enabled per method if needed.
Paginated and enriched envelopes
The map callback can also reshape richer responses. If a service returns { items, total }, lift the pagination details into meta and expose only the rows in data.
interface Page<T> {
items: T[];
total: number;
page: number;
}
return next.handle().pipe(
map((payload: Page<unknown>) => ({
data: payload.items,
meta: {
total: payload.total,
page: payload.page,
timestamp: new Date().toISOString(),
},
})),
);
Because map is pure, it never mutates the original payload — it returns a fresh object — which keeps the transformation predictable and easy to unit test.
Best practices
- Keep the interceptor generic (
NestInterceptor<T, R>) so handler return types survive the wrap and stay visible to clients of typed SDKs. - Use
maprather thantap:mapis for reshaping the value,tapis for side effects only. - Provide an explicit opt-out (
@RawResponse()+Reflector) for downloads, webhooks, and health checks that must return raw bodies. - Read the real
statusCodefrom the response object instead of hard-coding200, so201/204routes report correctly. - Bind the interceptor with
APP_INTERCEPTORso it lives in the DI container and can inject services later without refactoring. - Define the envelope shape as a shared interface and reuse it across the API, your tests, and any generated client types.
- Keep the
mapcallback free of I/O and side effects — transformation should be a pure function of the emitted payload.