
June 01, 2025
Structuring Clean API Responses in NestJS with Interceptors and Pagination
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 IInterceptor
interface. 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 User
entity 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 aUser
entity into anIUserResponse
DTO. - It implements the
NestInterceptor
interface with a specific input and output type (User
toIUserResponse
), making the transformation explicit and type-safe. - The
intercept()
method taps into the response stream (an observable) and maps the rawUser
into the formatted response using the inheritedtransform()
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'sfindAndCount()
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 thepaginate()
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
84 views