Pagination Patterns

Page-Based, Offset, or Cursor — Pick One

Pagination Patterns

Don't return fifty thousand rows. Page the response, and pick the pagination style that matches how the client will consume it.

4 min read Level 2/5 #nestjs#pagination#data
What you'll learn
  • Offset pagination with skip / take
  • Cursor pagination for stable ordering
  • Return next / prev links to the client

A list endpoint should never return “everything.” Page it. The question is which style of pagination to use — and the answer depends on what the client will do with the data.

Offset Pagination

The classic ?page=3&size=20 query. Easy to implement, easy for humans to reason about, but slow on deep pages because the database has to scan the rows it’s skipping.

@Get()
async list(
  @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
  @Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
) {
  const [items, total] = await this.repo.findAndCount({
    order: { createdAt: 'DESC' },
    take: size,
    skip: (page - 1) * size,
  });

  return {
    items,
    total,
    page,
    pageCount: Math.ceil(total / size),
  };
}

findAndCount returns the rows and the total in a single round trip. Good for traditional “Page 1 of 12” UIs.

Cursor Pagination

For infinite scroll, social feeds, or anything where new rows appear at the top, offsets are wrong — they shift when data is inserted. Use a cursor: an opaque pointer to a specific row.

@Get()
async list(
  @Query('cursor') cursor?: string,
  @Query('size', new DefaultValuePipe(20), ParseIntPipe) size?: number,
) {
  const where = cursor ? { id: LessThan(Number(cursor)) } : {};
  const items = await this.repo.find({
    where,
    order: { id: 'DESC' },
    take: size + 1,
  });

  const hasMore = items.length > size;
  if (hasMore) items.pop();

  return {
    items,
    nextCursor: hasMore ? items[items.length - 1].id : null,
  };
}

The take: size + 1 trick is how you detect “is there a next page” with one query: peek one extra row, then drop it before returning.

Whichever style you pick, give the client enough metadata to call you again. For offset: total, page, pageCount. For cursor: nextCursor (and prevCursor if you support going back).

If you want to be properly REST-y, return RFC 5988 Link headers:

res.setHeader(
  'Link',
  `<${url}?cursor=${nextCursor}>; rel="next"`,
);

Which One?

  • Offset — admin tables, search results, anything with a “Page X of Y” affordance.
  • Cursor — infinite scroll, activity feeds, exports, any list where new rows keep landing.

When in doubt, cursor. It’s more code up front but scales better and behaves correctly when data shifts under you.

Per-Environment Database Config →