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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A Link header | The response body is the collection itself, with no room for metadata | Clients have to read a header, and some HTTP clients make that awkward from a browser | The body already has an envelope with room for links |
| A next URL in the body | The response is an envelope, and you want the position visible to a person | The body shape becomes part of your contract, so every client parses it | Responses are bare arrays you do not want to wrap |
| Page numbers in the body | Interfaces that show a pager with numbered pages to a person | Total pages needs a count, which is the expensive query on a large collection | Nobody is displaying page numbers |
| Advertising nothing | An internal endpoint with one caller you own | Every client encodes your paging rules, so changing them breaks all of them at once | The 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.
Related how-tos
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.