Skip to content

Pagination

Veloce-TS ships with a complete set of pagination helpers for both offset-based (page/limit) and cursor-based pagination patterns.


Offset pagination uses page and limit query parameters to navigate through pages of results.

The paginate() standalone helper builds a { data, meta } response in a single call:

import { Controller, Get, Query, paginate } from 'veloce-ts';
import { z } from 'zod';
const PaginationSchema = z.object({
page: z.string().transform(Number).default('1'),
limit: z.string().transform(Number).default('10'),
});
@Controller('/products')
class ProductController {
@Get('/')
async list(@Query(PaginationSchema) query: z.infer<typeof PaginationSchema>) {
const { page, limit } = query;
const offset = (page - 1) * limit;
const [data, total] = await Promise.all([
db.select().from(products).limit(limit).offset(offset),
db.select({ count: sql<number>`count(*)` }).from(products),
]);
return paginate(data, total[0].count, page, limit);
}
}

Response structure:

{
"data": [...],
"meta": {
"total": 150,
"page": 2,
"limit": 10,
"totalPages": 15,
"hasNext": true,
"hasPrev": true,
"from": 11,
"to": 20
}
}

For more control, use the PaginationHelper class:

import { PaginationHelper } from 'veloce-ts';
@Get('/')
async list(@Query() query: any) {
// Parses page/limit from query with defaults and max limit enforcement
const { page, limit } = PaginationHelper.parsePaginationQuery(
query,
10, // defaultLimit
100 // maxLimit
);
const offset = (page - 1) * limit;
const data = await db.select().from(products).limit(limit).offset(offset);
const total = await db.$count(products);
return PaginationHelper.paginate(data, total, page, limit);
}

Cursor-based pagination is more efficient for large datasets and real-time feeds. Instead of a page number, you use an opaque cursor pointing to a specific record.

import { Controller, Get, Query, createCursorPaginatedResult, parseCursorQuery } from 'veloce-ts';
import { gt, lt, eq, and } from 'drizzle-orm';
@Controller('/posts')
class PostController {
@Get('/')
async list(@Query() query: any) {
// Safely parse cursor and limit from query params
const { cursor, limit } = parseCursorQuery(query, 20, 100);
let rows;
if (cursor) {
// Decode the cursor to get the last seen ID
const lastId = parseInt(atob(cursor));
rows = await db
.select()
.from(posts)
.where(gt(posts.id, lastId))
.limit(limit + 1); // fetch one extra to detect hasNext
} else {
rows = await db
.select()
.from(posts)
.limit(limit + 1);
}
return createCursorPaginatedResult(rows, limit, 'id');
}
}

Response structure:

{
"data": [...],
"meta": {
"count": 20,
"hasNext": true,
"hasPrev": false,
"nextCursor": "eyJpZCI6MjB9",
"prevCursor": null
}
}
Terminal window
# First page
GET /posts
# Next page (use nextCursor from previous response)
GET /posts?cursor=eyJpZCI6MjB9
# Previous page (use prevCursor)
GET /posts?cursor=eyJpZCI6MX0=&direction=prev
createCursorPaginatedResult(
data: T[],
limit: number,
cursorField: keyof T,
hadPrevCursor?: boolean // pass true when navigating forward with a cursor
): CursorPaginatedResult<T>

The hadPrevCursor parameter (added in v0.4.0) correctly sets hasPrev: true when you’re not on the first page.

const { cursor } = parseCursorQuery(query);
const result = createCursorPaginatedResult(
rows,
limit,
'id',
!!cursor // hasPrev = true whenever a cursor was provided
);

When sorting by non-unique fields (e.g. createdAt), you need a composite cursor using multiple fields for stable ordering.

import { createMultiCursor, decodeMultiCursor } from 'veloce-ts';
@Get('/')
async list(@Query() query: any) {
const { cursor, limit } = parseCursorQuery(query, 20);
let where;
if (cursor) {
// Decode composite cursor: { createdAt, id }
const decoded = decodeMultiCursor(cursor);
where = or(
lt(posts.createdAt, decoded.createdAt),
and(
eq(posts.createdAt, decoded.createdAt),
lt(posts.id, decoded.id)
)
);
}
const rows = await db
.select()
.from(posts)
.where(where)
.orderBy(desc(posts.createdAt), desc(posts.id))
.limit(limit + 1);
// Encode multi-field cursor from the last item
const lastItem = rows[rows.length - 1];
const nextCursor = lastItem
? createMultiCursor(lastItem, ['createdAt', 'id'])
: null;
return {
data: rows.slice(0, limit),
meta: {
hasNext: rows.length > limit,
nextCursor,
count: Math.min(rows.length, limit),
},
};
}

interface PaginationMeta {
total: number; // total number of items
page: number; // current page (1-based)
limit: number; // items per page
totalPages: number; // total number of pages
hasNext: boolean; // true if there's a next page
hasPrev: boolean; // true if there's a previous page
from: number; // first item index on this page (1-based, e.g. 11)
to: number; // last item index on this page (1-based, e.g. 20)
}
interface CursorPaginatedResult<T> {
data: T[];
meta: {
count: number; // actual items returned in this page
hasNext: boolean;
hasPrev: boolean;
nextCursor: string | null;
prevCursor: string | null;
};
}

Prevent clients from requesting thousands of records in one call:

const { page, limit } = PaginationHelper.parsePaginationQuery(query, 10, 100);
// limit is capped at 100 even if client sends limit=9999

2. Use cursor pagination for large or real-time datasets

Section titled “2. Use cursor pagination for large or real-time datasets”

Offset pagination becomes slow with large offsets (OFFSET 50000). Use cursor pagination for feeds, timelines, or tables with millions of rows.

3. Always sort by indexed columns for cursor pagination

Section titled “3. Always sort by indexed columns for cursor pagination”

Cursor pagination only works correctly when sorting by an indexed, stable field. Use id (primary key) or composite (createdAt, id).

4. Return pagination metadata in all list endpoints

Section titled “4. Return pagination metadata in all list endpoints”
// ✓ Good - clients can build pagination UI
return paginate(data, total, page, limit);
// ✗ Bad - clients don't know if there are more pages
return data;

5. Document your pagination parameters with OpenAPI decorators

Section titled “5. Document your pagination parameters with OpenAPI decorators”
@Get('/')
@Summary('List products with pagination')
@Description('Supports both offset (?page=1&limit=10) and cursor (?cursor=xxx&limit=10) pagination')
async list(@Query() query: any) { ... }