Overview
Kioto Teteria Backend implements offset-based pagination using a centralized PaginationOptions utility class. This provides consistent pagination across all list endpoints.
How It Works
Pagination uses two query parameters:
page - The page number (1-indexed)
pageSize - Number of items per page
The system returns paginated data with metadata about the total count and page information.
The PaginationOptions class handles pagination logic:
export class PaginationOptions {
private static readonly DEFAULT_PAGE = 1 ;
private static readonly DEFAULT_PAGE_SIZE = 10 ;
private static readonly MAX_PAGE_SIZE = 50 ;
static resolve ( params : PaginationParams ) {
const page = params . page ?? this . DEFAULT_PAGE ;
const pageSize = params . pageSize ?? this . DEFAULT_PAGE_SIZE ;
if ( page < 1 ) {
throw new BadRequestException ( 'Page must be greater than or equal to 1' );
}
if ( pageSize < 1 || pageSize > this . MAX_PAGE_SIZE ) {
throw new BadRequestException (
`Page size must be between 1 and ${ this . MAX_PAGE_SIZE } ` ,
);
}
const skip = ( page - 1 ) * pageSize ;
return {
page ,
pageSize ,
skip ,
take: pageSize ,
};
}
static buildMeta (
total : number ,
page : number ,
pageSize : number ,
) : PaginationMeta {
return {
total ,
page ,
pageSize ,
totalPages: Math . ceil ( total / pageSize ),
};
}
}
From src/common/pagination/pagination-options.ts:15
Configuration
Default page number when not specified
Default number of items per page
Maximum allowed page size to prevent performance issues
Implementation Example
Here’s how pagination is implemented in the products service:
Controller
@ Get ()
findAll (@ Query ( 'page' ) page ?: string , @ Query ( 'pageSize' ) pageSize ?: string ) {
return this . productsService . findAll ({
page: page ? Number . parseInt ( page , 10 ) : undefined ,
pageSize: pageSize ? Number . parseInt ( pageSize , 10 ) : undefined ,
});
}
From src/modules/products/products.controller.ts:24
Service
async findAll ( params : FindAllParams ) {
const { page , pageSize , skip , take } = PaginationOptions . resolve ( params );
const { categoryId } = params ;
const where = {
... ( categoryId && { categoryId }),
};
const [ products , total ] = await this . prisma . $transaction ([
this . prisma . product . findMany ({
where ,
skip ,
take ,
orderBy: {
createdAt: 'desc' ,
},
}),
this . prisma . product . count ({ where }),
]);
return {
data: products ,
meta: PaginationOptions . buildMeta ( total , page , pageSize ),
};
}
From src/modules/products/products.service.ts:19
Request Examples
Request without parameters uses defaults (page 1, 10 items):
Custom Page Size
GET /products?pageSize= 20
Specific Page
GET /products?page= 2 & pageSize = 10
Maximum Page Size
GET /products?pageSize= 50
Paginated endpoints return this structure:
{
"data" : [
{
"id" : 1 ,
"name" : "Green Tea" ,
"slug" : "green-tea" ,
"description" : "Premium green tea" ,
"price" : "15.99" ,
"isActive" : true ,
"categoryId" : 1 ,
"createdAt" : "2024-01-15T10:30:00.000Z" ,
"updatedAt" : "2024-01-15T10:30:00.000Z"
}
],
"meta" : {
"total" : 45 ,
"page" : 1 ,
"pageSize" : 10 ,
"totalPages" : 5
}
}
Response Fields
Pagination metadata Total number of items across all pages
Current page number (1-indexed)
Total number of pages available
Validation
The pagination system validates input parameters:
Page Validation
if ( page < 1 ) {
throw new BadRequestException ( 'Page must be greater than or equal to 1' );
}
Invalid:
Valid:
Page Size Validation
if ( pageSize < 1 || pageSize > this . MAX_PAGE_SIZE ) {
throw new BadRequestException (
`Page size must be between 1 and ${ this . MAX_PAGE_SIZE } ` ,
);
}
Invalid:
pageSize=0
pageSize=51 (exceeds maximum)
pageSize=100
Valid:
pageSize=1
pageSize=25
pageSize=50
Database Queries
Prisma’s skip and take implement offset-based pagination:
const { skip , take } = PaginationOptions . resolve ({ page: 2 , pageSize: 10 });
// skip = 10, take = 10
await this . prisma . product . findMany ({
skip , // Skip first 10 records
take , // Take next 10 records
});
Offset Calculation
The offset is calculated as:
const skip = ( page - 1 ) * pageSize ;
Examples:
Page 1, pageSize 10: skip = 0
Page 2, pageSize 10: skip = 10
Page 3, pageSize 20: skip = 40
Using Transactions
To ensure data consistency, fetch the data and count in a transaction:
const [ products , total ] = await this . prisma . $transaction ([
this . prisma . product . findMany ({ where , skip , take }),
this . prisma . product . count ({ where }),
]);
This guarantees both queries see the same database snapshot, preventing race conditions where items are added/removed between queries.
Type Definitions
Input parameters for pagination:
export interface PaginationParams {
page ?: number ;
pageSize ?: number ;
}
Metadata included in responses:
export interface PaginationMeta {
total : number ;
page : number ;
pageSize : number ;
totalPages : number ;
}
From src/common/pagination/pagination-options.ts:3
Error Responses
Invalid Page Number
{
"statusCode" : 400 ,
"message" : "Page must be greater than or equal to 1" ,
"error" : "Bad Request"
}
Invalid Page Size
{
"statusCode" : 400 ,
"message" : "Page size must be between 1 and 50" ,
"error" : "Bad Request"
}
Offset-based pagination can be slow for large datasets with high page numbers. Consider cursor-based pagination for tables with millions of records.
Why MAX_PAGE_SIZE?
The 50-item limit prevents:
Database performance degradation
Large response payloads
Memory issues on the client
Best Practices
Use transactions for consistency
Always fetch data and counts in a transaction to prevent inconsistent pagination metadata.
Apply consistent ordering
Always include orderBy to ensure deterministic results across pages. orderBy : { createdAt : 'desc' }
Parse query params carefully
Query parameters are strings. Parse to numbers before passing to the service layer. page : page ? Number . parseInt ( page , 10 ) : undefined
Don't exceed MAX_PAGE_SIZE
The limit protects your API. Users requesting all data should use multiple requests or a different endpoint.
To modify pagination defaults:
Update constants in PaginationOptions class
Adjust MAX_PAGE_SIZE based on your performance testing
Consider adding cursor-based pagination for large datasets
Cursor-Based Alternative
For very large datasets, consider cursor-based pagination:
// Using a cursor (e.g., ID or timestamp)
{
cursor : { id : lastSeenId },
take : pageSize ,
}
This is more efficient for deep pagination but requires different client-side logic.