# 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.

Source: https://voxgig.com/howto/link-headers-for-pagination-rfc-8288

- Audience: api-producer
- Level: beginner
- Languages: typescript, javascript
- Verified: 2026-09-06
- Published: 2026-09-06

## 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](https://www.rfc-editor.org/rfc/rfc8288), and the relation names used here are registered
in [the IANA link relations registry](https://www.iana.org/assignments/link-relations/link-relations.xhtml).

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A Link header](https://www.rfc-editor.org/rfc/rfc8288) | 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](https://docs.stripe.com/api/pagination) | 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](https://jsonapi.org/format/#fetching-pagination) | 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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/200) | 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.

```ts title="link.mjs"
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.

```ts title="link.mjs"
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.

```bash
node walk.mjs
```

```text output
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.

```bash
node --test link.test.mjs
```

```text output
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

- [Choose a pagination style for a list endpoint](/howto/choose-a-pagination-style-for-a-list-endpoint)

- [Negotiate page size between an API and its clients](/howto/negotiate-page-size-with-clients)

## 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.