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.
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 }
} Link Headers (RFC 5988)
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 →