Implementing Pagination

1. Using Query Parameters

ParamDefaultNotes
page11-based
limit20Cap at 100
cursorOpaque token (alt. to page)

Example: Parse pagination

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

Example: Offset formula

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.
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

SettingRecommended
Default limit20
Max limit100
Min limit1

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)
});

8. Implementing Cursor-Based Pagination

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
  });
});
PaginationBest For
OffsetSmall/medium datasets, jump to page
CursorLarge feeds, infinite scroll, stable order RECOMMENDED
KeysetCursor 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 }
});