Loading...

Cleaner Pagination in ASP.NET Core Web API (Headers + Link)

Return only items in the body and move pagination metadata to headers. Add RFC 5988 Link header (next/prev) for clean, predictable API navigation.

When building a REST API, pagination metadata often ends up inside the response body. That works, but it mixes data and navigation metadata in the same payload. A cleaner approach is to keep the response body focused on the items, and put pagination info in HTTP headers.

In this example, the API returns customers as a simple JSON array, while pagination metadata is exposed using X-Total-Count, X-Page, X-Page-Size, X-Total-Pages and an RFC 5988 Link header for next/prev navigation.


Goal


Controller Example (Items in Body, Metadata in 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)
{
    // Server-side limits (prevents abuse)
    page = Math.Max(1, page);
    pageSize = Math.Clamp(pageSize, 1, 100);

    // Your service returns: items + totalCount
    var (items, totalCount) = await _customerService.ListPagedAsync(page, pageSize);

    // total pages (0 is valid when totalCount = 0)
    var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);

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

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

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

    return Ok(items);
}

// Generates Link header like:
// <https://host/api/customers?page=1&pageSize=20&sort=name>; rel="prev", <...>; rel="next"
private string BuildLinkHeader(int page, int pageSize, int totalPages)
{
    // If there are no pages (totalCount = 0), no navigation links are needed.
    if (totalPages <= 1)
        return string.Empty;

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

    // Preserve existing query parameters (filter/sort/search...)
    var query = Request.Query
        .ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToString());

    // Ensure current pageSize is present/consistent
    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 (for a complete copy/paste sample)
public sealed record Customer(string Name);

The response body stays minimal (just the items), while the client reads pagination metadata from headers. Pagination links also keep existing query parameters (such as sort, filter, search) so navigation is reliable.


Example Response Body

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

Example Response Headers


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"

Notice how sort=name (and any other query parameters) stay in the generated Link URLs.


Fetch Example (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 metadata from 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"));

// navigation via Link header (next/prev)
const linkHeader = response.headers.get("Link");

// parse the Link header into { next, prev }
const links = parseLinkHeader(linkHeader);
console.log(links.next, links.prev);

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

  // Example:
  // <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;
}

With a small parseLinkHeader helper, your UI can navigate pages using links.next and links.prev without embedding metadata in the response body.


CORS Note (Reading Custom Headers)

If your API and frontend run on different domains, the browser’s CORS policy may prevent JavaScript from reading certain response headers. In that case, calls like response.headers.get("X-Total-Count") can return null.

To make these headers readable, the API must expose them via Access-Control-Expose-Headers. The example below defines and applies a 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();

// ... other middleware (e.g., UseRouting)

// Apply CORS before endpoints
app.UseCors("Frontend");

// ... UseAuthentication / UseAuthorization etc.

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

After that, pagination metadata headers become readable from JavaScript.


Why Link Header?

The Link header helps clients discover pagination navigation in a standard way:


Common Improvements


TL;DR

  • Return only items in the response body.
  • Expose pagination metadata via headers: X-Total-Count, X-Page, X-Page-Size.
  • Add an RFC 5988 Link header for next/prev navigation.
  • Preserve existing query parameters so pagination links stay correct.