Pagination

Limit/Offset and Cursor Pagination with Link Headers

Pagination

Implement limit/offset and cursor-based pagination in a Koa REST API, exposing total count in response bodies and RFC 5988 Link headers for navigation.

4 min read Level 2/5 #koa#pagination#rest
What you'll learn
  • Parse and validate limit and offset query parameters
  • Return a total count alongside paginated results
  • Set RFC 5988 Link headers for next and previous pages

Most collections grow beyond what a single HTTP response should carry. Pagination tells the client “here are 20 items; here is how to ask for the next 20.” There are two mainstream approaches: limit/offset (simple) and cursor-based (consistent under concurrent writes).

Limit / Offset

Query parameters ?limit=20&offset=0 are the simplest form. Parse and cap them defensively:

// src/middleware/paginate.js
export function paginate(ctx, next) {
  const limit  = Math.min(Number(ctx.query.limit)  || 20, 100);
  const offset = Math.max(Number(ctx.query.offset) || 0,  0);
  ctx.state.pagination = { limit, offset };
  return next();
}

In the service, run both the data query and a count query:

// src/services/articles.js
export async function findAll({ limit, offset }) {
  const [rows, [{ count }]] = await Promise.all([
    db("articles").select("*").limit(limit).offset(offset),
    db("articles").count("id as count"),
  ]);
  return { data: rows, total: Number(count), limit, offset };
}

Return everything in the response body:

export async function listArticles(ctx) {
  const result = await ArticleService.findAll(ctx.state.pagination);
  ctx.body = result;   // { data, total, limit, offset }
}

Some clients — and API gateways — prefer Link headers for navigation instead of (or in addition to) body fields:

function buildLinkHeader(ctx, { total, limit, offset }) {
  const base  = `${ctx.origin}${ctx.path}`;
  const links = [];
  if (offset + limit < total) {
    links.push(`<${base}?limit=${limit}&offset=${offset + limit}>; rel="next"`);
  }
  if (offset > 0) {
    const prev = Math.max(offset - limit, 0);
    links.push(`<${base}?limit=${limit}&offset=${prev}>; rel="prev"`);
  }
  return links.join(", ");
}

export async function listArticles(ctx) {
  const result = await ArticleService.findAll(ctx.state.pagination);
  const link   = buildLinkHeader(ctx, result);
  if (link) ctx.set("Link", link);
  ctx.body = result;
}

Cursor Pagination

Cursor-based pagination is stable when rows are inserted or deleted between requests. The last seen ID (or timestamp) becomes the cursor:

export async function findAfterCursor({ cursor, limit = 20 }) {
  const query = db("articles").select("*").orderBy("id").limit(limit);
  if (cursor) query.where("id", ">", cursor);
  const rows = await query;
  return {
    data:       rows,
    nextCursor: rows.length === limit ? rows.at(-1).id : null,
  };
}

Return nextCursor in the response body so the client passes it as ?cursor=<value> on the next request. There is no total count with cursor pagination — that is the trade-off.

Up Next

Learn how to serve different response formats from the same endpoint using content negotiation.

Content Negotiation →