blog bg

June 01, 2025

Structuring Clean API Responses in NestJS with Interceptors and Pagination

Share what you learn in this blog to prepare for your interview, create your forever-free profile now, and explore how to monetize your valuable knowledge.

 

When building robust backend APIs, one of the key elements that affects developer experience and frontend integration is how consistent your responses are. In this post, I'll walk you through how I handle consistent API responses using NestJS interceptors and set up a centralized pagination utility for all list endpoints.

 

What is an Interceptor in NestJS?

In NestJS, an interceptor is a class that implements the NestInterceptor interface. It sits between the request and the route handler, or between the route handler and the response, and gives you the power to transform, extend, or handle logic before or after a route is executed.

 

Some common use cases include:

  • Transforming responses
  • Logging requests
  • Caching
  • Binding extra data to requests
  •  

Think of it as middleware, but with more brains, context and flexibility.

 

 Observable Streams: Why .pipe(map())?

NestJS heavily uses RxJS observables. When you return data in your route handlers or services, it flows through the interceptor as an observable stream. By calling .pipe(map(...)), we’re modifying the response just before it is sent to the client.

 

Let's look at how I implemented a global interceptor to control all app-wide responses.

 

 App-Wide Response Interceptor

src/interceptors/response.interceptor.ts

 

 

 

import { Observable } from "rxjs";
import { map } from "rxjs/operators";
import {
 Injectable,
 NestInterceptor,
 ExecutionContext,
 CallHandler
} from "@nestjs/common";

@Injectable()
export class TransformResponseInterceptor implements NestInterceptor {
 intercept(_context: ExecutionContext, next: CallHandler): Observable<any> {
   return next.handle().pipe(
     map((data) => ({
       success: true,
       data
     }))
   );
 }
}

 

With this interceptor:

  • Every successful API response is wrapped in the structure:

 

 

 

 

 {
   "success": true,
   "data": {...}
 }


- This ensures predictability, especially for frontend integration.

 

Registering the Global Interceptor

Instead of applying this interceptor manually in every controller or in main.ts, we use NestJS' global providers:

 

src/app.module.ts

 

 

import { Module } from "@nestjs/common";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { LogModule } from "./log/log.module";
import { APP_FILTER, APP_INTERCEPTOR } from "@nestjs/core";
import { GlobalExceptionFilters } from "./exceptions/global.exception";
import { LogService } from "./log/log.service";
import { TransformResponseInterceptor } from "./interceptors/response.interceptor";

@Module({
 imports: [LogModule],
 controllers: [AppController],
 providers: [
   {
     provide: APP_FILTER,
     useClass: GlobalExceptionFilters
   },
   {
     provide: APP_INTERCEPTOR,
     useClass: TransformResponseInterceptor
   },
   AppService,
   LogService
 ]
})
export class AppModule {}

 

This method:

  • Automatically applies the TransformResponseInterceptor across the entire application
  • Keeps the main bootstrap (main.ts) clean
  • Also shows how we globally register exception filters like GlobalExceptionFilters

 

Setting Up the User Resource

Next we scaffold a simple User resource.

 

1. Install TypeORM Dependencies

If you haven't already, install the necessary TypeORM and PostgreSQL dependencies:

 

 

npm install @nestjs/typeorm typeorm pg

 

2. Generate the User Module

Run the NestJS CLI command to generate a module, controller, and service for the user:

 

 

npx nest g resource users

When prompted, choose REST API and answer No to generating CRUD endpoints automatically. We will define only the ones we need.

 

3. Create the User Entity

Now define the User entity with basic fields: full name and email.

src/users/entities/user.entity.ts

 

 

 

import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from "typeorm"

@Entity()
export class User {
 @PrimaryGeneratedColumn("uuid")
 id: string
 @Column({ length: 100 })
 fullName: string
 @Column({ unique: true })
 email: string
 @CreateDateColumn()
 createdAt: Date
 @UpdateDateColumn()
 updatedAt: Date
}

 

4. Create Basic Endpoints

For now, we'll implement just three endpoints:

  • POST /users to create a user
  •  GET /users to fetch all users
  • GET /users/:id to fetch a user by ID

 

users.service.ts

 

 

// src/users/users.service.ts
import { Injectable, NotFoundException } from "@nestjs/common"
import { InjectRepository } from "@nestjs/typeorm"
import { Repository } from "typeorm"
import { User } from "./entities/user.entity"
import { CreateUserDto } from "./dto/create-user.dto"

@Injectable()
export class UsersService {
 constructor(
   @InjectRepository(User)
   private usersRepository: Repository<User>
 ) {}
 
 create(createUserDto: CreateUserDto) {
   const user = this.usersRepository.create(createUserDto)
   return this.usersRepository.save(user)
 }
 
 findAll() {
   return this.usersRepository.find()
 }
 
 async findOne(id: string) {
   const user = await this.usersRepository.findOne({ where: { id } })
   if (!user) throw new NotFoundException("User not found")
   return user
 }
}

 

users.controller.ts

 

 

// src/users/users.controller.ts
import { Controller, Post, Body, Get, Param } from "@nestjs/common"
import { UsersService } from "./users.service"
import { CreateUserDto } from "./dto/create-user.dto"

@Controller("users")
export class UsersController {
 constructor(private readonly usersService: UsersService) {}
 
 @Post()
 create(@Body() createUserDto: CreateUserDto) {
   return this.usersService.create(createUserDto)
 }
 
 @Get()
 findAll() {
   return this.usersService.findAll()
 }
 
 @Get(":id")
 findOne(@Param("id") id: string) {
   return this.usersService.findOne(id)
 }
}

 

create-user.dto.ts

 

 

// src/users/dto/create-user.dto.ts
export class CreateUserDto {
 fullName: string
 email: string
}

 

Setting Up Pagination Structure

Most of your resources, e.g., users, will be listed in tables or dashboards. Rather than repeating logic, let's create a dedicated pagination module.

 

Generate Pagination Module & Service

 

 

nest g module pagination
nest g service pagination
  • This will scaffold the files under src/pagination/

 

 Create Pagination Interfaces

To keep things modular and type-safe, we define a few interfaces.

src/pagination/interfaces/pagination-params.interface.ts

 

 

 

export interface PaginationParams {
 page?: number;
 limit?: number;
}

This is the input format we'll accept in query parameters. If nothing is provided, defaults will be used.

 

src/pagination/interfaces/pagination-result.interface.ts

 

 

 

export interface PaginatedResult<T> {
 items: T[];
 metadata: {
   total: number;
   page: number;
   limit: number;
   totalPages: number;
   hasNextPage: boolean;
   hasPreviousPage: boolean;
 };
}

This is the output structure every paginated response will follow, ensuring consistency across all list endpoints.

 

Implementing the Pagination Service


Next, we'll build the actual paginate() helper in our pagination service, and show how to use it in the UsersService.

This service will take in a dataset, the total count, and the pagination parameters (page and limit) and return a structured PaginatedResult.

 

 

// src/pagination/pagination.service.ts
import { Injectable } from "@nestjs/common"
import { PaginationParams } from "./interfaces/pagination-params.interface"
import { PaginatedResult } from "./interfaces/pagination-result.interface"

@Injectable()
export class PaginationService {
 paginate<T>(data: T[], total: number, paginationParams: PaginationParams): PaginatedResult<T> {
   const { page = 1, limit = 10 } = paginationParams
   const totalPages = Math.ceil(total / limit)
   return {
     items: data,
     metadata: {
       total,
       page,
       limit,
       totalPages,
       hasNextPage: page < totalPages,
       hasPreviousPage: page > 1
     }
   }
 }
}

This utility will come in handy when building endpoints that return large sets of data like users. We'll integrate this into the UsersService in the coming steps.

 

Structuring Response Interfaces and Creating a Response Mapper

To maintain consistent and controlled output from our API, we first define a set of interfaces and a base class that all resource interceptors can follow. This helps ensure that our data shape is predictable and client-friendly.

 

1. Base Interceptor Contract

We begin by defining a simple interface that every interceptor-specific response mapper must implement:

 

 

// src/index.d.ts
interface IInterceptor {
 transform(data: unknown): unknown
}

This ensures that any class we create to handle transformation will have a standard transform method.

 

2. Defining Response Types for the User Resource

Next, we define the expected output format for a single user and a paginated list of users:

// src/users/interfaces/user-response.interface.ts

 

 

export interface IUserResponse {
 id: string;
 email: string;
 fullName: string;
 createdAt: string;
}


 src/users/interfaces/users-response.interface.ts

 

 

import { PaginatedResult } from "src/pagination/interfaces/pagination-result.interface"
import { IUserResponse } from "./user-response.interface"
export type IUsersResponse = PaginatedResult<IUserResponse>

This structure makes it easier to provide strong typing across our application when formatting responses.

 

 3. Abstract Mapper for the User Resource

Now, let's create an abstract class that implements the IInterceptorinterface. This class will handle the logic of transforming the raw User entity into our desired response format:

 

 

// src/users/interfaces/user-response-mapper.ts
import { User } from "../entities/user.entity"
import { IUserResponse } from "./user-response.interface"

export abstract class UserResponseMapper implements IInterceptor {
 transform(user: User): IUserResponse {
   return {
     id: user.id,
     fullName: user.fullName,
     email: user.email,
     createdAt: user.createdAt.toISOString()
   }
 }
}

This mapper will serve as the foundation for the actual interceptors we'll build next, enabling a clean separation between how data is stored internally and how it's exposed externally.

 

With this groundwork laid, we're ready to build our custom interceptors for both single and paginated responses.

 

Building Interceptors for Controlled Output

With our TransformResponseInterceptor in place for app-wide formatting and a mapper to control how data is shaped per resource, we can now implement custom interceptors tailored to specific use cases. In this case, handling the user resource.

 

Single Resource Interceptor

We start with the interceptor for transforming a single Userentity into the desired shape:

src/users/interceptors/user.interceptor.ts

 

 

 

import { map, Observable } from "rxjs"
import { User } from "../entities/user.entity"
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common"
import { IUserResponse } from "../interfaces/user-response.interface"
import { UserResponseMapper } from "../interfaces/user-response-mapper"

@Injectable()
export class UserInterceptor extends UserResponseMapper implements NestInterceptor<User, IUserResponse> {
 intercept(_context: ExecutionContext, next: CallHandler<User>): Observable<IUserResponse> {
   return next.handle().pipe(map((data) => this.transform(data)))
 }
}

 

What's happening here?

  •  This class extends the UserResponseMapper, so it inherits the logic to transform a User entity into an IUserResponse DTO.
  • It implements the NestInterceptor interface with a specific input and output type (User to IUserResponse), making the transformation explicit and type-safe.
  • The intercept() method taps into the response stream (an observable) and maps the raw User into the formatted response using the inherited transform() method.

 

Paginated List Interceptor

For paginated results (a list of users), we implement a second interceptor that also relies on the PaginationService we created earlier:

src/users/interceptors/users.interceptor.ts

 

 

 

import { map, Observable } from "rxjs"
import { User } from "../entities/user.entity"
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common"
import { IUserResponse } from "../interfaces/user-response.interface"
import { UserResponseMapper } from "../interfaces/user-response-mapper"
import { IUsersResponse } from "../interfaces/users-response.interface"
import { PaginationService } from "src/pagination/pagination.service"
import { PaginationParams } from "src/pagination/interfaces/pagination-params.interface"

type PayloadType = [User[], number]

@Injectable()
export class UsersInterceptor extends UserResponseMapper implements NestInterceptor<PayloadType, IUsersResponse> {
 constructor(private paginationService: PaginationService) {
   super()
 }
 
 intercept(context: ExecutionContext, next: CallHandler<PayloadType>): Observable<IUsersResponse> {
   const request = context.switchToHttp().getRequest()
   const { page, limit } = request.query
   return next.handle().pipe(map((data) => this.paginate(data, { page, limit })))
 }
 
 paginate([users, total]: PayloadType, params: PaginationParams): IUsersResponse {
   const data = users.map((user) => this.transform(user))
   return this.paginationService.paginate<IUserResponse>(data, total, params)
 }
}

 

Explanation:

  • This interceptor is designed to work with a tuple payload [User[], totalCount], which is the default return shape of TypeORM's findAndCount() method.
  • Just like the single resource interceptor, it extends the UserResponseMapper to reuse the transformation logic.
  •  It also injects the PaginationService to compute pagination metadata and structure the result accordingly.
  • The interceptor reads pagination parameters (page, limit) directly from the request query and passes them into the paginate() method.

 

With these interceptors in place, your controller methods can stay focused on handling business logic, while the response formatting and pagination handling remain centralized and reusable.

 

Next up, we'll apply these interceptors to your controller methods to see them in action.

 

Connecting It All in the Controller

Now that we've set up the interceptors, we can plug them into our UsersController to automatically format responses for both single and multiple user requests. Here's what the controller looks like:

 

 

 

@Controller("users")
export class UsersController {
 constructor(private readonly usersService: UsersService) {}
 
 @Post()
 @UseInterceptors(UserInterceptor)
 create(@Body() createUserDto: CreateUserDto) {
   return this.usersService.create(createUserDto)
 }
 
 @Get()
 @UseInterceptors(UsersInterceptor)
 findAll(@Query() query: PaginationParams) {
   return this.usersService.findAll(query)
 }
 
 @Get(":id")
 @UseInterceptors(UserInterceptor)
 findOne(@Param("id") id: string) {
   return this.usersService.findOne(id)
 }
}

 

Notice how clean and minimal the controller looks. There's no need to manually transform or paginate anything here. The interceptors take care of all that for us.

 

The Service: Pagination with TypeORM

The findAll method in the UsersService uses TypeORM's findAndCount to fetch a paginated list of users and the total count in one query:

 

 

 

findAll(params: PaginationParams) {
 const { page = 1, limit = 10 } = params
 return this.usersRepository.findAndCount({
   take: limit,
   skip: page ? (page - 1) * limit : undefined
 })
}

This result, a tuple of, is [users, total] what our UsersInterceptor expects and uses to return a paginated response.

 

Sample Responses

Let's take a look at what responses from our API now look like with the interceptors in action.

POST /users

Request Body:

 

 

 

{
 "fullName": "Jane Doe",
 "email": "jane@example.com"
}

 

Response:

 

 

{
 "success": true,
 "statusCode": 201,
 "message": "OK",
 "data": {
   "id": "6ff7bd7f-1e21-42c0-bf3f-b8a98bffbdef",
   "fullName": "Jane Doe",
   "email": "jane@example.com",
   "createdAt": "2025-05-26T11:45:00.123Z"
 }
}

 

 GET /users?page=1&limit=2

Response:

 

 

{
 "success": true,
 "data": {
   "items": [
     {
       "id": "6ff7bd7f-1e21-42c0-bf3f-b8a98bffbdef",
       "fullName": "Jane Doe",
       "email": "jane@example.com",
       "createdAt": "2025-05-26T11:45:00.123Z"
     },
     {
       "id": "fbf3ecaf-2209-4df1-ae53-43ae39fc9173",
       "fullName": "John Smith",
       "email": "john@example.com",
       "createdAt": "2025-05-24T14:25:00.002Z"
     }
   ],
   "metadata": {
     "total": 12,
     "page": 1,
     "limit": 2,
     "totalPages": 6,
     "hasNextPage": true,
     "hasPreviousPage": false
   }
 }
}

 

GET /users/6ff7bd7f-1e21-42c0-bf3f-b8a98bffbdef

Response:

 

 

{
 "success": true,
 "statusCode": 200,
 "message": "OK",
 "data": {
   "id": "6ff7bd7f-1e21-42c0-bf3f-b8a98bffbdef",
   "fullName": "Jane Doe",
   "email": "jane@example.com",
   "createdAt": "2025-05-26T11:45:00.123Z"
 }
}

 

Conclusion

By taking a modular, layered approach with interceptors and mappers, we've achieved a clean separation of concerns:

  •  Controller logic focuses solely on routing and delegating work to services.
  • Services handle fetching data and applying business logic.
  • Interceptors format and transform responses into well-structured, consistent shapes.
  • Pagination and metadata are automatically handled for any list-based resources.

 

This approach not only improves maintainability and scalability but also enforces a consistent API contract across your application's clients, and frontend developers will thank you for it.

 

You can now easily apply the same pattern to other resources in your application with minimal duplication.

Source Code: https://github.com/Intuneteq/nestjs-response-interceptors

85 views

Please Login to create a Question