Ejemplos de paginación de API: offset, página, cursor, keyset, cabecera Link y HAL

Seis estilos de paginación sobre los mismos datos, con una respuesta real y un bucle que recorre todas las páginas para cada uno.

Ejemplos ejecutados por última vez contra la API el

Las APIs dividen las listas largas en páginas de seis formas habituales. Offset y limit saltan un número de elementos. Número y tamaño de página cuentan páginas, como hace Spring Data. Un cursor es un token opaco, como el que usa Stripe, y la paginación keyset pide los elementos posteriores al último id. Algunas APIs ponen la URL siguiente en una cabecera Link, como GitHub, y HAL la pone en enlaces hipermedia. https://api.testingapis.com/api/paginationinicia sesión para verlo sirve los mismos 100 elementos en los seis estilos, para que compares las respuestas una al lado de otra y pruebes un cliente contra cada uno. Es gratuita y no necesita clave. Abajo tienes cada estilo con una respuesta real.

Los seis estilos de un vistazo

EstiloPeticiónLa página siguiente sale deLo usa
Offset / limit/offset?offset=20&limit=10offset + limitMuchas APIs sobre SQL
Número de página (desde 0)/page?page=2&size=10number + 1, hasta que last sea trueSpring Data
Cursor/cursor?limit=10&cursor=…next_cursor, hasta que has_more sea falseStripe
Keyset/keyset?after_id=20&limit=10next_after_idTablas grandes, feeds
Cabecera Link (desde 1)/link?page=3&per_page=10La URL rel="next" de la cabecera LinkGitHub
HAL/hal?page=2&size=10_links.next.hrefSpring HATEOAS

Todas las rutas están bajo /api/paginationinicia sesión para verlo. El tamaño de página va de 1 a 100, con 10 por defecto.

Offset y limit

El estilo más sencillo: salta offset elementos y devuelve los limit siguientes. El total está en el cuerpo y en X-Total-Count.

Terminal
$ curl -i "https://api.testingapis.com/api/pagination/offset?offset=0&limit=2"
HTTP/1.1 200 OK
x-total-count: 100

{"data":[{"id":1,"name":"Smart Headset",...},{"id":2,"name":"Compact Speaker",...}],
 "pagination":{"offset":0,"limit":2,"total":100,"hasMore":true}}
Este ejemplo usa endpoints para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita

Número y tamaño de página

El sobre de Spring Data. Las páginas empiezan en 0, y ese es el bug que hay que probar: un cliente escrito para páginas que empiezan en 1 se salta la primera página sin decir nada.

Terminal
$ curl "https://api.testingapis.com/api/pagination/page?page=0&size=2"

{"content":[{"id":1,...},{"id":2,...}],
 "pageable":{"pageNumber":0,"pageSize":2,"offset":0,"paged":true},
 "totalElements":100,"totalPages":50,"number":0,"size":2,"numberOfElements":2,
 "first":true,"last":false,"empty":false}
Este ejemplo usa endpoints para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita

Cursor

La respuesta trae un next_cursor. Devuélvelo sin tocarlo para obtener la página siguiente, y para cuando has_more sea false. El cursor resulta ser JSON en base64, pero un cliente debe tratarlo como opaco. Un cursor alterado recibe un 400:

Terminal
$ curl "https://api.testingapis.com/api/pagination/cursor?limit=2"

{"object":"list","url":"/api/pagination/cursor",
 "data":[{"id":1,...},{"id":2,...}],
 "has_more":true,"next_cursor":"eyJsYXN0SWQiOjJ9"}

$ curl "https://api.testingapis.com/api/pagination/cursor?cursor=garbage"
HTTP/1.1 400 Bad Request

{"status":400,"error":"Bad Request","message":"Invalid cursor. Pass the 'next_cursor' value from a previous response unchanged."}
Este ejemplo usa endpoints para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita
GET/api/pagination/cursor?limit=3
Esta petición usa un endpoint para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita

Keyset

Como un cursor, pero legible: pide los elementos con un id mayor que el último que viste. Las inserciones y borrados en páginas anteriores no pueden desplazar los resultados, y por eso lo usan los feeds y las tablas muy grandes.

Terminal
$ curl "https://api.testingapis.com/api/pagination/keyset?after_id=10&limit=2"

{"data":[{"id":11,"name":"Smart Lamp",...},{"id":12,"name":"Compact Headset",...}],
 "has_more":true,"next_after_id":12}
Este ejemplo usa endpoints para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita

El cuerpo es un array simple, y la navegación va en la cabecera Link (RFC 8288), con URLs absolutas para next, prev, first y last. Una página más allá del final devuelve un array vacío, no un 404.

Terminal
$ curl -i "https://api.testingapis.com/api/pagination/link?page=2&per_page=2"
HTTP/1.1 200 OK
link: <https://api.testingapis.com/api/pagination/link?page=3&per_page=2>; rel="next",
      <https://api.testingapis.com/api/pagination/link?page=50&per_page=2>; rel="last",
      <https://api.testingapis.com/api/pagination/link?page=1&per_page=2>; rel="first",
      <https://api.testingapis.com/api/pagination/link?page=1&per_page=2>; rel="prev"
x-total-count: 100

[{"id":3,"name":"Deluxe Blender",...},{"id":4,"name":"Eco Bottle",...}]
Este ejemplo usa endpoints para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita

HAL

Los elementos están bajo _embedded.items, cada uno con su propio enlace self, y los enlaces de página están bajo _links. El tipo de contenido es application/hal+json, que algunos clientes no analizan como JSON si no se lo indicas.

Terminal
$ curl "https://api.testingapis.com/api/pagination/hal?page=0&size=2"

{"_embedded":{"items":[
   {"id":1,...,"_links":{"self":{"href":"/api/pagination/items/1"}}},
   {"id":2,...,"_links":{"self":{"href":"/api/pagination/items/2"}}}]},
 "_links":{"self":{"href":"/api/pagination/hal?page=0&size=2"},
           "first":{"href":"/api/pagination/hal?page=0&size=2"},
           "next":{"href":"/api/pagination/hal?page=1&size=2"},
           "last":{"href":"/api/pagination/hal?page=49&size=2"}},
 "page":{"size":2,"totalElements":100,"totalPages":50,"number":0}}
Este ejemplo usa endpoints para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita

Recorrer todas las páginas

Casi todos los bugs de paginación aparecen cuando un cliente recorre la lista entera: para una página antes, entra en bucle en la última o cuenta las páginas desde el número equivocado. Este script obtiene los 100 elementos dos veces, una por cursor y otra por cabecera Link. Las dos veces termina en el id 100, así que no se saltó ni se repitió nada.

paginate.mjs
const BASE = "https://api.testingapis.com/api/pagination";

// Cursor: follow next_cursor until has_more is false.
async function allByCursor(limit = 25) {
  const items = [];
  let cursor;
  do {
    const url = new URL(`${BASE}/cursor`);
    url.searchParams.set("limit", limit);
    if (cursor) url.searchParams.set("cursor", cursor);
    const page = await (await fetch(url)).json();
    items.push(...page.data);
    cursor = page.has_more ? page.next_cursor : undefined;
  } while (cursor);
  return items;
}

// Link header: follow rel="next" until there is none.
async function allByLinkHeader(perPage = 25) {
  const items = [];
  let url = `${BASE}/link?per_page=${perPage}`;
  while (url) {
    const res = await fetch(url);
    items.push(...(await res.json()));
    const next = res.headers.get("link")?.match(/<([^>]+)>;\s*rel="next"/);
    url = next?.[1];
  }
  return items;
}

const byCursor = await allByCursor();
const byLink = await allByLinkHeader();
console.log("cursor:", byCursor.length, "items, last id", byCursor.at(-1).id);
console.log("link:  ", byLink.length, "items, last id", byLink.at(-1).id);
Este ejemplo usa endpoints para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita
Salida
cursor: 100 items, last id 100
link:   100 items, last id 100

Qué estilo elegir

  • Offset o número de página cuando los usuarios saltan a la página N y los datos cambian despacio. Una fila insertada o borrada entre dos peticiones desplaza todas las páginas siguientes, así que un elemento puede saltarse o aparecer dos veces.
  • Cursor o keyset para feeds, scroll infinito y exportaciones, donde los datos cambian mientras alguien pasa páginas. No puedes saltar directamente a la página 40.
  • Cabecera Link o HAL cuando quieres que los clientes sigan las URLs que les da el servidor en lugar de construir las suyas. Así el servidor puede cambiar el esquema de paginación sin romperlos.