Pagination
Cursor-Based (recommended for large datasets)
app.get('/api/products', async (req, res) => {
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const cursor = req.query.cursor as string | undefined;
const where: any = {};
if (cursor) {
where.id = { gt: cursor };
}
const items = await db.product.findMany({
where,
take: limit + 1, // Fetch one extra to check hasMore
orderBy: { id: 'asc' },
});
const hasMore = items.length > limit;
if (hasMore) items.pop();
res.json({
data: items,
pagination: {
hasMore,
nextCursor: hasMore ? items[items.length - 1].id : null,
},
});
});
Offset-Based (simple, good for small datasets)
app.get('/api/products', async (req, res) => {
const page = Math.max(parseInt(req.query.page as string) || 1, 1);
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const offset = (page - 1) * limit;
const [items, total] = await Promise.all([
db.product.findMany({ skip: offset, take: limit, orderBy: { createdAt: 'desc' } }),
db.product.count(),
]);
res.json({
data: items,
pagination: {
page, limit, total,
totalPages: Math.ceil(total / limit),
hasMore: offset + items.length < total,
},
});
});
Filtering and Sorting
app.get('/api/products', async (req, res) => {
const { sort = 'createdAt', order = 'desc', category, minPrice, maxPrice, search } = req.query;
const where: any = {};
if (category) where.category = category;
if (minPrice || maxPrice) {
where.price = {};
if (minPrice) where.price.gte = parseFloat(minPrice as string);
if (maxPrice) where.price.lte = parseFloat(maxPrice as string);
}
if (search) where.name = { contains: search, mode: 'insensitive' };
const items = await db.product.findMany({
where,
orderBy: { [sort as string]: order },
take: limit,
skip: offset,
});
res.json({ data: items, pagination: { /* ... */ } });
});
Spring Boot (Pageable)
@GetMapping("/products")
public Page<ProductDto> list(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "createdAt,desc") String[] sort) {
Pageable pageable = PageRequest.of(page, Math.min(size, 100),
Sort.by(Sort.Direction.fromString(sort[1]), sort[0]));
return productRepo.findAll(pageable).map(mapper::toDto);
}
Comparison
| Strategy |
Pros |
Cons |
Best For |
| Offset |
Simple, jump to page |
Slow on large tables, skip drift |
Admin panels, small datasets |
| Cursor |
Fast, stable with inserts |
Can't jump to page N |
Feeds, infinite scroll, large datasets |
| Keyset |
Fast, no skip drift |
Complex multi-column sort |
Time-series, ordered data |
Anti-Patterns
| Anti-Pattern |
Fix |
| No max page size |
Cap limit (e.g., max 100) |
| COUNT(*) on huge tables |
Use cursor pagination, skip total count |
| Offset on millions of rows |
Use cursor or keyset pagination |
| Returning all fields |
Select only needed fields, support fields param |
| No default sorting |
Always define default sort for stable results |
Production Checklist
1---2name: pagination3description: API pagination patterns. Offset-based, cursor-based, keyset pagination. Filtering, sorting, and page metadata. REST and GraphQL pagination implementations. USE WHEN: user mentions "pagination", "paginate", "cursor", "offset", "page size", "next page", "infinite scroll API", "list endpoint" DO NOT USE FOR: frontend infinite scroll UI - use frontend framework skills; database query optimization - use database skills4---5# Pagination67## Cursor-Based (recommended for large datasets)89```typescript10app.get('/api/products', async (req, res) => {11 const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);12 const cursor = req.query.cursor as string | undefined;1314 const where: any = {};15 if (cursor) {16 where.id = { gt: cursor };17 }1819 const items = await db.product.findMany({20 where,21 take: limit + 1, // Fetch one extra to check hasMore22 orderBy: { id: 'asc' },23 });2425 const hasMore = items.length > limit;26 if (hasMore) items.pop();2728 res.json({29 data: items,30 pagination: {31 hasMore,32 nextCursor: hasMore ? items[items.length - 1].id : null,33 },34 });35});36```3738## Offset-Based (simple, good for small datasets)3940```typescript41app.get('/api/products', async (req, res) => {42 const page = Math.max(parseInt(req.query.page as string) || 1, 1);43 const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);44 const offset = (page - 1) * limit;4546 const [items, total] = await Promise.all([47 db.product.findMany({ skip: offset, take: limit, orderBy: { createdAt: 'desc' } }),48 db.product.count(),49 ]);5051 res.json({52 data: items,53 pagination: {54 page, limit, total,55 totalPages: Math.ceil(total / limit),56 hasMore: offset + items.length < total,57 },58 });59});60```6162## Filtering and Sorting6364```typescript65app.get('/api/products', async (req, res) => {66 const { sort = 'createdAt', order = 'desc', category, minPrice, maxPrice, search } = req.query;6768 const where: any = {};69 if (category) where.category = category;70 if (minPrice || maxPrice) {71 where.price = {};72 if (minPrice) where.price.gte = parseFloat(minPrice as string);73 if (maxPrice) where.price.lte = parseFloat(maxPrice as string);74 }75 if (search) where.name = { contains: search, mode: 'insensitive' };7677 const items = await db.product.findMany({78 where,79 orderBy: { [sort as string]: order },80 take: limit,81 skip: offset,82 });8384 res.json({ data: items, pagination: { /* ... */ } });85});86```8788## Spring Boot (Pageable)8990```java91@GetMapping("/products")92public Page<ProductDto> list(93 @RequestParam(defaultValue = "0") int page,94 @RequestParam(defaultValue = "20") int size,95 @RequestParam(defaultValue = "createdAt,desc") String[] sort) {9697 Pageable pageable = PageRequest.of(page, Math.min(size, 100),98 Sort.by(Sort.Direction.fromString(sort[1]), sort[0]));99 return productRepo.findAll(pageable).map(mapper::toDto);100}101```102103## Comparison104105| Strategy | Pros | Cons | Best For |106|----------|------|------|----------|107| Offset | Simple, jump to page | Slow on large tables, skip drift | Admin panels, small datasets |108| Cursor | Fast, stable with inserts | Can't jump to page N | Feeds, infinite scroll, large datasets |109| Keyset | Fast, no skip drift | Complex multi-column sort | Time-series, ordered data |110111## Anti-Patterns112113| Anti-Pattern | Fix |114|--------------|-----|115| No max page size | Cap `limit` (e.g., max 100) |116| COUNT(*) on huge tables | Use cursor pagination, skip total count |117| Offset on millions of rows | Use cursor or keyset pagination |118| Returning all fields | Select only needed fields, support `fields` param |119| No default sorting | Always define default sort for stable results |120121## Production Checklist122123- [ ] Maximum page size enforced (e.g., 100)124- [ ] Default sort order defined125- [ ] Cursor pagination for large/growing datasets126- [ ] Input validation on page/limit/sort params127- [ ] Consistent response envelope (`data`, `pagination`)