Pagination
Pagination
Section titled “Pagination”Veloce-TS ships with a complete set of pagination helpers for both offset-based (page/limit) and cursor-based pagination patterns.
Table of Contents
Section titled “Table of Contents”- Offset Pagination
- Cursor Pagination
- Multi-Field Cursor Pagination
- Pagination Metadata
- Best Practices
Offset Pagination
Section titled “Offset Pagination”Offset pagination uses page and limit query parameters to navigate through pages of results.
Quick Start with paginate()
Section titled “Quick Start with paginate()”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 }}Using PaginationHelper
Section titled “Using PaginationHelper”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 Pagination
Section titled “Cursor Pagination”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.
Quick Start
Section titled “Quick Start”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 }}Navigating with Cursors
Section titled “Navigating with Cursors”# First pageGET /posts
# Next page (use nextCursor from previous response)GET /posts?cursor=eyJpZCI6MjB9
# Previous page (use prevCursor)GET /posts?cursor=eyJpZCI6MX0=&direction=prevcreateCursorPaginatedResult API
Section titled “createCursorPaginatedResult API”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);Multi-Field Cursor Pagination
Section titled “Multi-Field Cursor Pagination”When sorting by non-unique fields (e.g. createdAt), you need a composite cursor using multiple fields for stable ordering.
Creating a Multi-Field Cursor
Section titled “Creating a Multi-Field Cursor”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), }, };}Pagination Metadata
Section titled “Pagination Metadata”PaginationMeta Structure (v0.4.0)
Section titled “PaginationMeta Structure (v0.4.0)”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)}CursorPaginatedResult Structure
Section titled “CursorPaginatedResult Structure”interface CursorPaginatedResult<T> { data: T[]; meta: { count: number; // actual items returned in this page hasNext: boolean; hasPrev: boolean; nextCursor: string | null; prevCursor: string | null; };}Best Practices
Section titled “Best Practices”1. Always set a maximum limit
Section titled “1. Always set a maximum limit”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=99992. 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 UIreturn paginate(data, total, page, limit);
// ✗ Bad - clients don't know if there are more pagesreturn 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) { ... }Next Steps
Section titled “Next Steps”- Learn about ORM Integration to connect your database
- Explore Caching to cache paginated results
- Read the Decorators Guide for
@Querypatterns