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
- Den Response-Body sauber halten: nur Items zurückgeben.
- Pagination-Metadaten in Headern bereitstellen.
- Einen
Link-Header hinzufügen, damit Clientsnext/prev-Seiten ermitteln können. - Vorhandene Query-Parameter (filter/sort/search) beim Erzeugen der Pagination-Links beibehalten.
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:
rel="next"zeigt auf die URL der nächsten Seite.rel="prev"zeigt auf die URL der vorherigen Seite.- Clients können navigieren, ohne URL-Formate erraten zu müssen.
Häufige Verbesserungen
- Für bessere Navigation
rel="first"undrel="last"hinzufügen. - Eine maximale
pageSizedurchsetzen, um Missbrauch zu verhindern (Math.Clampist ein schneller Gewinn). - Beim Erzeugen der Link-URLs alle Query-Parameter (filter/sort/search) beibehalten (dieses Beispiel macht das bereits).
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ürnext/prev-Navigation hinzufügen. - Vorhandene Query-Parameter beibehalten, damit Pagination-Links korrekt bleiben.