Wird geladen...

Saubere Pagination in ASP.NET Core Web API (Header + Link)

Geben Sie nur Items im Body zurück und verschieben Sie Pagination-Metadaten in Header. Nutzen Sie den RFC 5988 Link-Header für next/prev Navigation.

Bei der Entwicklung einer REST-API werden Pagination-Metadaten häufig direkt im Response-Body mitgeliefert. Das funktioniert, vermischt jedoch Daten und Navigations-Metadaten in einem Payload. Ein sauberer Ansatz ist, den Response-Body ausschließlich auf die Items zu konzentrieren und die Pagination-Informationen in HTTP-Header zu verschieben.

In diesem Beispiel liefert die API Kunden als einfaches JSON-Array zurück, während die Pagination-Metadaten über X-Total-Count, X-Page, X-Page-Size, X-Total-Pages sowie einen RFC 5988 Link-Header für die next/prev-Navigation bereitgestellt werden.


Ziel


Controller-Beispiel (Items im Body, Metadaten in Headern)


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)
{
    // Serverseitige Limits (verhindert Missbrauch)
    page = Math.Max(1, page);
    pageSize = Math.Clamp(pageSize, 1, 100);

    // Service liefert: items + totalCount
    var (items, totalCount) = await _customerService.ListPagedAsync(page, pageSize);

    // Gesamtseiten (0 ist gültig, wenn totalCount = 0)
    var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);

    // Pagination-Metadaten in Headern
    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();

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

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

    return Ok(items);
}

// Erzeugt einen Link-Header wie:
// <https://host/api/customers?page=1&pageSize=20&sort=name>; rel="prev", <...>; rel="next"
private string BuildLinkHeader(int page, int pageSize, int totalPages)
{
    // Wenn es keine Seiten gibt (totalCount = 0) oder nur eine Seite, sind keine Navigationslinks nötig.
    if (totalPages <= 1)
        return string.Empty;

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

    // Vorhandene Query-Parameter (filter/sort/search...) beibehalten
    var query = Request.Query
        .ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToString());

    // pageSize konsistent setzen
    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);
}

// Optional (damit das Beispiel direkt copy/paste funktioniert)
public sealed record Customer(string Name);

Der Response-Body bleibt minimal (nur items), während der Client die Pagination-Metadaten aus den Headern liest. Zusätzlich bleiben die Pagination-Links zuverlässig, weil bestehende Query-Parameter (sort, filter, search) in den erzeugten URLs erhalten bleiben.


Beispiel Response-Body


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

Beispiel Response-Header


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"

In diesem Beispiel bleibt sort=name (und weitere Query-Parameter) in den erzeugten Link-URLs erhalten.


Fetch-Beispiel (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();

// Pagination-Metadaten aus den Headern
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"));

// Navigation über den Link-Header (next/prev)
const linkHeader = response.headers.get("Link");

// Link-Header in { next, prev } parsen
const links = parseLinkHeader(linkHeader);
console.log(links.next, links.prev);

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

  // Beispiel:
  // <https://...page=1&pageSize=20>; rel="prev", <https://...page=3&pageSize=20>; 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;
}

Mit einem kleinen parseLinkHeader-Helper kann die Client-UI über links.next und links.prev navigieren, ohne Pagination-Metadaten in den Response-Body einzubetten.


CORS-Hinweis (Benutzerdefinierte Header lesen)

Wenn API und Frontend auf unterschiedlichen Domains laufen, kann die CORS-Policy des Browsers verhindern, dass JavaScript bestimmte Response-Header ausliest. In diesem Fall können Aufrufe wie response.headers.get("X-Total-Count") null zurückgeben.

Damit diese Header auslesbar sind, müssen sie über Access-Control-Expose-Headers auf API-Seite freigegeben werden. Das folgende Beispiel definiert und aktiviert eine CORS-Policy in 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();

// ... weitere Middleware (z. B. UseRouting)

// CORS vor den Endpoints anwenden
app.UseCors("Frontend");

// ... UseAuthentication / UseAuthorization usw.

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

Danach sind die Pagination-Metadaten-Header in JavaScript auslesbar.


Warum ein Link-Header?

Der Link-Header macht Pagination-Navigation auf standardisierte Weise auffindbar:


Häufige Verbesserungen


TL;DR

  • Im Response-Body nur Items zurückgeben.
  • Pagination-Metadaten über Header bereitstellen: X-Total-Count, X-Page, X-Page-Size.
  • Einen RFC 5988 Link-Header für next/prev-Navigation hinzufügen.
  • Vorhandene Query-Parameter beibehalten, damit Pagination-Links korrekt bleiben.