Cargando...

Paginación limpia en ASP.NET Core Web API (Headers + Link)

Devuelve solo los items en el body y mueve los metadatos de paginación a headers. Añade el Link header RFC 5988 (next/prev) para navegar.

Al crear una API REST, a menudo los metadatos de paginación terminan dentro del body de la respuesta. Funciona, pero mezcla datos y metadatos de navegación en el mismo payload. Un enfoque más limpio es mantener el body enfocado únicamente en los items y mover la información de paginación a los headers HTTP.

En este ejemplo, la API devuelve clientes como un simple array JSON, mientras que los metadatos de paginación se exponen mediante X-Total-Count, X-Page, X-Page-Size, X-Total-Pages y un header RFC 5988 Link para la navegación next/prev.


Objetivo


Ejemplo de Controller (Items en el body, metadatos en los headers)


using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;

[HttpGet("api/customers")]
public async Task<ActionResult<IEnumerable<Customer>>> GetCustomers(
    [FromQuery] int page = 1,
    [FromQuery] int pageSize = 20)
{
    // Límites del lado del servidor (evita abusos)
    page = Math.Max(1, page);
    pageSize = Math.Clamp(pageSize, 1, 100);

    // El servicio devuelve: items + totalCount
    var (items, totalCount) = await _customerService.ListPagedAsync(page, pageSize);

    // total de páginas (0 es válido si totalCount = 0)
    var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);

    // Metadatos de paginación en los headers
    Response.Headers["X-Total-Count"] = totalCount.ToString();
    Response.Headers["X-Page"] = page.ToString();
    Response.Headers["X-Page-Size"] = pageSize.ToString();
    Response.Headers["X-Total-Pages"] = totalPages.ToString();

    // Header Link: next/prev (RFC 5988)
    var links = BuildLinkHeader(page, pageSize, totalPages);

    if (!string.IsNullOrWhiteSpace(links))
        Response.Headers["Link"] = links;

    return Ok(items);
}

// Genera un header Link como:
// <https://host/api/customers?page=1&pageSize=20&sort=name>; rel="prev", <...>; rel="next"
private string BuildLinkHeader(int page, int pageSize, int totalPages)
{
    // Si no hay páginas (totalCount = 0) o solo hay una página, no se necesitan enlaces de navegación.
    if (totalPages <= 1)
        return string.Empty;

    var baseUrl = $"{Request.Scheme}://{Request.Host}{Request.Path}";

    // Conservar parámetros existentes (filter/sort/search...)
    var query = Request.Query
        .ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToString());

    // Asegurar pageSize coherente
    query["pageSize"] = pageSize.ToString();

    var parts = new List<string>();

    if (page > 1)
    {
        query["page"] = (page - 1).ToString();
        var prevUrl = QueryHelpers.AddQueryString(baseUrl, query);
        parts.Add($"<{prevUrl}>; rel=\"prev\"");
    }

    if (page < totalPages)
    {
        query["page"] = (page + 1).ToString();
        var nextUrl = QueryHelpers.AddQueryString(baseUrl, query);
        parts.Add($"<{nextUrl}>; rel=\"next\"");
    }

    return string.Join(", ", parts);
}

// Opcional (para un ejemplo completo copy/paste)
public sealed record Customer(string Name);

El body de la respuesta se mantiene mínimo (solo items), mientras que el cliente lee los metadatos de paginación desde los headers. Además, los enlaces de paginación son fiables porque los parámetros de consulta existentes (sort, filter, search) se conservan en las URL generadas.


Ejemplo de body de respuesta


[
  { "name": "John" },
  { "name": "Hans" },
  { "name": "Alex" },
  { "name": "Pearl" }
]

Ejemplo de headers de respuesta


X-Total-Count: 542
X-Page: 2
X-Page-Size: 20
X-Total-Pages: 28
Link: <https://api.site.com/api/customers?page=1&pageSize=20&sort=name>; rel="prev",
      <https://api.site.com/api/customers?page=3&pageSize=20&sort=name>; rel="next"

En este ejemplo, sort=name (y otros parámetros de consulta) se conserva en las URL Link generadas.


Ejemplo Fetch (JavaScript)


const response = await fetch(`/api/customers?page=${page}&pageSize=${pageSize}&sort=name`);

if (!response.ok) {
  throw new Error(`HTTP error! Status: ${response.status}`);
}

const items = await response.json();

// metadatos de paginación desde los headers
const totalCount  = Number(response.headers.get("X-Total-Count"));
const currentPage = Number(response.headers.get("X-Page"));
const pageSize    = Number(response.headers.get("X-Page-Size"));
const totalPages  = Number(response.headers.get("X-Total-Pages"));

// navegación mediante el header Link (next/prev)
const linkHeader = response.headers.get("Link");

// parsear el header Link a { next, prev }
const links = parseLinkHeader(linkHeader);
console.log(links.next, links.prev);

function parseLinkHeader(value) {
  if (!value) return {};

  // Ejemplo:
  // ; rel="prev", ; rel="next"
  const result = {};
  const parts = value.split(",");

  for (const part of parts) {
    const section = part.trim();
    const urlMatch = section.match(/<([^>]+)>/);
    const relMatch = section.match(/rel="([^"]+)"/);

    if (!urlMatch || !relMatch) continue;

    const url = urlMatch[1];
    const rel = relMatch[1];

    result[rel] = url;
  }

  return result;
}

Con un pequeño helper parseLinkHeader, la UI del cliente puede navegar mediante links.next y links.prev sin incrustar metadatos de paginación en el body de la respuesta.


Nota de CORS (leer headers personalizados)

Si la API y el frontend funcionan en dominios distintos, la política CORS del navegador puede impedir que JavaScript lea ciertos headers de respuesta. En ese caso, llamadas como response.headers.get("X-Total-Count") pueden devolver null.

Para que estos headers sean accesibles, la API debe exponerlos mediante Access-Control-Expose-Headers. El siguiente ejemplo define y aplica una policy CORS en ASP.NET Core Program.cs:


var builder = WebApplication.CreateBuilder(args);

builder.Services.AddCors(options =>
{
    options.AddPolicy("Frontend", policy =>
        policy.WithOrigins("https://frontend.site.com")
              .AllowAnyMethod()
              .AllowAnyHeader()
              .WithExposedHeaders(
                  "X-Total-Count",
                  "X-Page",
                  "X-Page-Size",
                  "X-Total-Pages",
                  "Link"));
});

var app = builder.Build();

// ... otros middlewares (p. ej., UseRouting)

// Aplicar CORS antes de los endpoints
app.UseCors("Frontend");

// ... UseAuthentication / UseAuthorization etc.

app.MapControllers();
app.Run();

Después de esto, los headers de metadatos de paginación se pueden leer desde JavaScript.


¿Por qué un header Link?

El header Link ayuda a descubrir la navegación de paginación de forma estándar:


Mejoras habituales


TL;DR

  • Devolver solo los items en el body de la respuesta.
  • Exponer los metadatos de paginación mediante headers: X-Total-Count, X-Page, X-Page-Size.
  • Añadir un header RFC 5988 Link para la navegación next/prev.
  • Conservar los parámetros de consulta existentes para que los enlaces de paginación sigan siendo correctos.