1. Using Query Parameters
| Param | Default | Notes |
page | 1 | 1-based |
limit | 20 | Cap at 100 |
cursor | — | Opaque token (alt. to page) |
function parsePagination(req) {
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 20));
return { page, limit, offset: (page - 1) * limit };
}
2. Calculating Offset
const offset = (page - 1) * limit;
// page=1,limit=20 → offset 0
// page=3,limit=20 → offset 40
3. Querying Database with Limits
Example: Prisma
const items = await prisma.user.findMany({ skip: offset, take: limit, orderBy: { id: "asc" } });
Example: SQL
SELECT * FROM users ORDER BY id LIMIT $1 OFFSET $2;
4. Returning Total Count
Example: Count + items
const [items, total] = await Promise.all([
prisma.user.findMany({ skip: offset, take: limit }),
prisma.user.count()
]);
res.json({ data: items, meta: { page, limit, total, pages: Math.ceil(total / limit) } });
Warning: COUNT(*) on huge tables is slow. Cache or use estimates (pg_class.reltuples) for very large datasets.
Example: HAL-style links
function pageLinks(req, page, pages) {
const url = (p) => `${req.baseUrl}${req.path}?page=${p}&limit=${req.query.limit || 20}`;
return {
self: url(page),
first: url(1),
last: url(pages),
prev: page > 1 ? url(page - 1) : null,
next: page < pages ? url(page + 1) : null
};
}
6. Setting Default Page Size
| Setting | Recommended |
| Default limit | 20 |
| Max limit | 100 |
| Min limit | 1 |
7. Validating Page Numbers
Example: zod schema
const paginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20)
});
Example: Cursor (id-based)
app.get("/feed", async (req, res) => {
const limit = Math.min(50, Number(req.query.limit) || 20);
const cursor = req.query.cursor; // last id seen
const items = await prisma.post.findMany({
take: limit + 1,
...(cursor && { cursor: { id: cursor }, skip: 1 }),
orderBy: { id: "desc" }
});
const hasMore = items.length > limit;
if (hasMore) items.pop();
res.json({
data: items,
nextCursor: hasMore ? items[items.length - 1].id : null
});
});
| Pagination | Best For |
| Offset | Small/medium datasets, jump to page |
| Cursor | Large feeds, infinite scroll, stable order RECOMMENDED |
| Keyset | Cursor variant using indexed column ranges |
const links = [];
if (page > 1) links.push(`<${url(page - 1)}>; rel="prev"`);
if (page < pages) links.push(`<${url(page + 1)}>; rel="next"`);
links.push(`<${url(1)}>; rel="first"`, `<${url(pages)}>; rel="last"`);
res.set("Link", links.join(", "));
res.set("X-Total-Count", String(total));
10. Handling Empty Results
Example: Empty page
res.status(200).json({
data: [],
meta: { page, limit, total: 0, pages: 0 },
links: { self: req.originalUrl, first: null, last: null, prev: null, next: null }
});