Home / Articles / One JSON Envelope for Every NestJS Route: Interceptors Plus a Global Filter

This article is published in English.

One JSON Envelope for Every NestJS Route: Interceptors Plus a Global Filter

Wrap every NestJS response in a typed envelope using a generic interceptor, a message decorator and an exception filter, while keeping controllers free of boilerplate.

1288 words

When every endpoint returns a slightly different shape, API clients have to code defensively. A consistent envelope lets consumers write one response handler instead of many. NestJS makes this possible without touching each controller: an interceptor transforms successful results, a decorator supplies per-route messages, and an exception filter gives errors the same shape. This guide builds all three.

Why wrapping responses in controllers does not scale

The naive approach assembles the envelope by hand in every handler:

@Get()
findAll() {
  const users = await this.userService.findAll();
  return {
    code: 200,
    status: true,
    message: 'Success retrieve users',
    data: users
  };
}

Repeated across dozens of routes, this duplication drifts over time. Controllers should accept input and return domain data; formatting the outgoing payload is a cross-cutting concern that belongs in the request lifecycle. (Note also that the snippet uses await in a method not marked async, which will not compile; one more reason to take the envelope out of the controllers.)

Defining the response contract

Start with types. IResponseEntity<T> is generic over the payload, and an optional meta object carries pagination details:

// src/common/interfaces/response.interface.ts

export interface ImetaPagination {
  page: number;
  limit: number;
  totalItems: number;
  totalPages: number;
  hasNextPage: boolean;
  hasPrevPage: boolean;
}

export interface IResponseEntity<T> {
  code: number;
  status: boolean;
  message: string;
  data?: T;
  meta?: ImetaPagination;
}

Per-route messages with a metadata decorator

A creation endpoint and a list endpoint deserve different success messages. SetMetadata attaches a value to the route handler, and a small wrapper gives it a readable name:

// src/common/decorators/response-message.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const RESPONSE_MESSAGE_METADATA = 'response_message';
export const ResponseMessage = (message: string) =>
  SetMetadata(RESPONSE_MESSAGE_METADATA, message);

The transform interceptor

The interceptor implements NestInterceptor<T, IResponseEntity<T>>, which documents that it turns a handler result of type T into an envelope. Before calling the handler it reads the current status code from the underlying response and fetches the custom message through Reflector, defaulting to 'Success'. Then it pipes the handler's observable through RxJS map. If the result is an object containing both data and meta, it is treated as a paginated result and split into the corresponding envelope fields; anything else becomes data directly.

// src/common/interceptors/transform-response.interceptor.ts
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { IResponseEntity } from '../interfaces/response.interface';
import { RESPONSE_MESSAGE_METADATA } from '../decorators/response-message.decorator';

@Injectable()
export class TransformResponseInterceptor<T>
  implements NestInterceptor<T, IResponseEntity<T>>
{
  constructor(private readonly reflector: Reflector) {}

  intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Observable<IResponseEntity<T>> {
    const http = context.switchToHttp();
    const response = http.getResponse();
    const statusCode = response.statusCode;

    // Extract custom message if set; default to 'Success'
    const customMessage =
      this.reflector.get<string>(
        RESPONSE_MESSAGE_METADATA,
        context.getHandler(),
      ) || 'Success';

    return next.handle().pipe(
      map((res) => {
        // Handle cases where service returns { data, meta } for pagination
        const hasMeta = res && typeof res === 'object' && 'meta' in res && 'data' in res;

        return {
          code: statusCode,
          status: true,
          message: customMessage,
          data: hasMeta ? res.data : res,
          meta: hasMeta ? res.meta : undefined,
        };
      }),
    );
  }
}

Some details worth knowing:

  • reflector.get() reads metadata from the handler only. If you want a class-level default message as well, getAllAndOverride() with both context.getHandler() and context.getClass() covers that.
  • The status is read before the handler runs. That picks up defaults and @HttpCode(), but if a handler changes the status dynamically, reading response.statusCode inside map is safer.
  • The pagination check is duck typing. A domain object that happens to have data and meta properties would be unwrapped by mistake; a dedicated class and an instanceof check is more robust.

Registering the interceptor for the whole app

Registering globally in main.ts applies the interceptor to every controller without @UseInterceptors() on each class. Because it is created outside the module system, the Reflector has to be fetched from the app and passed in manually:

// src/main.ts
import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
import { TransformResponseInterceptor } from './common/interceptors/transform-response.interceptor';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const reflector = app.get(Reflector);
  app.useGlobalInterceptors(new TransformResponseInterceptor(reflector));

  await app.listen(3000);
}
bootstrap();

The alternative is to register it as a provider under the APP_INTERCEPTOR token in the root module. Nest then constructs it with full dependency injection.

Controllers after the refactor

Controllers now return plain entities or a { data, meta } object and declare their message with the decorator:

// src/users/users.controller.ts
import { Controller, Get, Post, Body } from '@nestjs/common';
import { UsersService } from './users.service';
import { ResponseMessage } from '../common/decorators/response-message.decorator';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  @ResponseMessage('User successfully created')
  create(@Body() dto: CreateUserDto) {
    return this.usersService.create(dto);
  }

  @Get()
  @ResponseMessage('Users retrieved successfully')
  findAll() {
    // Returns { data: [...], meta: { page: 1, limit: 10, ... } }
    return this.usersService.findAllPaginated();
  }
}

A request to the list endpoint produces this envelope:

{
  "code": 200,
  "status": true,
  "message": "Users retrieved successfully",
  "data": [
    {
      "id": "usr_99",
      "name": "Alex Mercer",
      "email": "alex@example.com"
    }
  ],
  "meta": {
    "page": 1,
    "limit": 10,
    "totalItems": 1,
    "totalPages": 1,
    "hasNextPage": false,
    "hasPrevPage": false
  }
}

Giving errors the same shape

An interceptor's map only runs for values the handler successfully emits. Thrown HttpException instances, validation failures and unexpected errors bypass it and reach clients in Nest's default error format.

An exception filter closes that gap. The one below uses a bare @Catch(), so it handles every exception. It derives the status from HttpException instances, falling back to 500 otherwise. It extracts a message, joins arrays (such as the list produced by ValidationPipe) into one string, and responds with status: false and data: null:

// src/common/filters/http-exception.filter.ts
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';

@Catch()
export class AllExceptionsFilter 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 exceptionResponse =
      exception instanceof HttpException ? exception.getResponse() : null;

    const message =
      typeof exceptionResponse === 'object' && exceptionResponse !== null
        ? (exceptionResponse as any).message || exception.toString()
        : exception instanceof Error
        ? exception.message
        : 'Internal server error';

    response.status(status).json({
      code: status,
      status: false,
      message: Array.isArray(message) ? message.join(', ') : message,
      data: null,
    });
  }
}

Register it globally, for example with app.useGlobalFilters() or the APP_FILTER token. One caution: for non-HTTP errors this filter returns the raw exception.message to the client, which can leak internal details. In production, log the original error and send a generic message for 500 responses.

If you would rather adopt a standard error shape than a custom one, RFC 9457 problem details is worth a look.

Key takeaways

  • Keep controllers thin and let the lifecycle own the envelope.
  • A generic interceptor with RxJS map shapes successes; a metadata decorator customizes messages per route.
  • Interceptors never see thrown errors, so pair them with a global exception filter to keep both paths in the same format.
  • Never expose raw internal error messages.