How-to › Move data in and out

How to advertise next and previous pages with Link headers#

Send the next, previous, first and last page URLs in a Link header, so clients follow links you build rather than assembling query strings themselves.

Audience
API producer
Level
beginner
Topic
Paginate collections
Languages
TypeScript and JavaScript
Verified

Read first: Choose a pagination style for a list endpoint

Every client of your list endpoint has reimplemented the same loop: read page, add one, stop when the array comes back empty. One of them stops when the array is shorter than the page size, which is wrong on the page that happens to be exactly full. Another keeps requesting page 41 forever because your last page returns an empty array with a 200.

What you get

You will end up with a list endpoint that tells each client where the next page is, and a parser that reads the header correctly. This is for you if clients build your pagination URLs themselves.

Short answer

Put each page URL in a Link header as a bracketed URI with a quoted rel parameter, and omit the relations that do not apply on the current page. Clients then follow rel=“next” until it is absent rather than incrementing a page number and guessing when to stop. Parse the header by walking the bracketed URLs, because splitting on commas breaks any URL containing one.

You will need

An endpoint that returns a list, and Node 22 or later to run the examples. The header is defined by RFC 8288, and the relation names used here are registered in the IANA link relations registry.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A Link headerThe response body is the collection itself, with no room for metadataClients have to read a header, and some HTTP clients make that awkward from a browserThe body already has an envelope with room for links
A next URL in the bodyThe response is an envelope, and you want the position visible to a personThe body shape becomes part of your contract, so every client parses itResponses are bare arrays you do not want to wrap
Page numbers in the bodyInterfaces that show a pager with numbered pages to a personTotal pages needs a count, which is the expensive query on a large collectionNobody is displaying page numbers
Advertising nothingAn internal endpoint with one caller you ownEvery client encodes your paging rules, so changing them breaks all of them at onceThe endpoint is public or has more than one client

The header and the body carry the same information, and the difference is who has to change when the position changes. A client that follows a URL you built keeps working when you switch from offset to keyset. A client that builds ?page=N does not, and it will not find out until rows go missing.

Build the header

Bracket the URL, quote the relation, and leave out anything that does not apply.

export function buildLink(entries) {
  return entries
    .filter(([, url]) => url)
    .map(([rel, url]) => `<${url}>; rel="${rel}"`)
    .join(', ')
}

Leaving relations out is what turns the header into a stopping condition a client can rely on. On the last page there is no next, so a client following links reaches the end without counting rows or comparing lengths. On the first page there is no prev. A header that always carries all four, with the out-of-range ones pointing at pages that do not exist, gives a client nothing to test.

Absolute URLs are the safer choice. Relative references are legal, and every client then needs a base to resolve them against. That is one more thing to get wrong in a language whose URL library resolves paths differently from yours.

Parse it without splitting on commas

The obvious parser is wrong, and the bug takes months to surface because it needs a URL with a comma in it.

export function parseLink(header) {
  const out = {}
  if (!header) return out
  const re = /<([^>]*)>\s*;\s*([^,]*)/g
  let m
  while ((m = re.exec(header))) {
    const [, url, params] = m
    const rel = /rel\s*=\s*"?([^";]+)"?/.exec(params)?.[1]
    if (rel) for (const one of rel.trim().split(/\s+/)) out[one] = url
  }
  return out
}

The URL comes out of the angle brackets first, so a comma inside it is never treated as a separator. A sort parameter such as ?sort=name,id is enough to break the naive version. The failure is that the client sees two malformed entries instead of one good one, and neither of them has a usable rel.

The relation value can also carry more than one name. A single link with rel="next last" is legal and says the next page is also the final one, so a parser that reads the value as a single string misses both. Splitting the value on whitespace and recording each name costs one line, which is the loop in the preceding code. Relation names are case-insensitive, so lowercase each one before you compare it, and treat an unknown name as a link worth keeping rather than an error.

Check it worked

Walking the collection is the test. A client that follows links should visit every row once and stop on its own.

node walk.mjs
page 1: ids 1,2,3  rels next,first,last
page 2: ids 4,5,6  rels next,prev,first,last
page 3: ids 7  rels prev,first,last
walked 3 pages, 7 rows, no duplicates: true

Page one has no prev and page three has no next, which is what stopped the loop. The third page holds one row, and nothing in the client had to reason about that. A client that had been comparing row counts against the page size would have made a fourth request to find out.

node --test link.test.mjs
1..4
# tests 4
# suites 0
# pass 4
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 102.117571

When it goes wrong

The header is there and the browser cannot read it. A cross-origin response exposes only the safe listed headers to JavaScript, so a front end reading Link needs the server to name it in Access-Control-Expose-Headers. Without that the header arrives, the browser hides it, and the client falls back to building URLs.

The second failure is a header that grows past what a proxy will carry. Four relations, each with a long signed cursor, can push one header line past the limit some intermediaries impose. The response is then rejected or truncated somewhere you do not control. Send next and prev only when the URLs are long, and leave first and last for collections whose ends are cheap to name.

When not to do this

Do not put a last relation on a keyset-paginated collection. Naming the last page needs a count of everything, which is the query keyset pagination exists to avoid. A caller who follows it pays for an answer that was stale on arrival.

Do not send a Link header and a next URL in the body that disagree. Two sources of truth for the same position means clients pick one, and the one they pick is the one you forget to update.

Do not encode paging state a client should not see into a URL you hand back. The URL is opaque to a well-behaved client and completely visible to any other kind.

Do not treat the header as an alternative to documenting the endpoint. A client author still has to be told that following next is the supported way to walk the collection, because a page parameter that also works will keep being used.

Last verified

Verified 2026-09-06 against Node 22.22.2. Both output blocks are what the preceding command printed, against a local server holding seven rows.

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 22 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.