Remigiusz ZalewskiRemigiusz Zalewski

Pagination in .NET & React

pagination-dotnet-react

Introduction

The CRUD movies app from the previous build works fine with ten rows and falls over as a UX (and eventually a payload-size problem) the moment there are ten thousand. This video adds real server-side pagination to the same API and wires a React Pagination component up to it - the page number and page size travel as query parameters, and the total counts travel back as response metadata instead of being baked into the JSON body.

🎬 Watch the full video here:


A pagination params type that clamps itself

Rather than trusting the client to send a sane page size, the params object caps it server-side:

public class PaginationParams
{
    public int PageNumber { get; set; } = 1;

    public int PageSize
    {
        get => _pageSize;
        set => _pageSize = value > MaximumPageSize ? MaximumPageSize : value;
    }

    private int _pageSize = 10;
    private const int MaximumPageSize = 100;
}

A client requesting pageSize=100000 silently gets capped at 100 rather than the API trying to serialize the entire table because a query string parameter said so. This is the kind of defensive default that costs nothing until the one day it prevents a genuinely bad request from taking the API down.

Skip/Take, translated to SQL by EF Core

The actual pagination logic is a generic helper reused across every paginated query in the app:

public static class CollectionHelper<T>
{
    public static async Task<PaginatedList<T>> ToPaginatedList(IQueryable<T> source, int pageNumber, int pageSize)
    {
        var count = await source.CountAsync();
        var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync();
        return new PaginatedList<T>(items, count, pageNumber, pageSize);
    }
}

Because source is still IQueryable<T> when Skip/Take are applied, EF Core translates both into the SQL OFFSET/FETCH clause - the database returns only the requested page's rows, not the entire table with the API discarding the rest in memory. The separate CountAsync() call runs its own lightweight COUNT(*) query rather than counting the already-fetched page.

Metadata in a header, not the response body

Rather than wrapping every paginated response in an envelope object, pagination metadata travels in a custom response header:

public class PaginationResult(int pageSize, int pageNumber, int totalItems, int totalPages, IEnumerable<object> items) : IResult
{
    public async Task ExecuteAsync(HttpContext httpContext)
    {
        var header = new { pageSize, pageNumber, totalItems, totalPages };

        httpContext.Response.Headers.Append("X-Pagination", JsonSerializer.Serialize(header));
        httpContext.Response.Headers.Append("Access-Control-Expose-Headers", "X-Pagination");
        await httpContext.Response.WriteAsJsonAsync(items);
    }
}

The response body stays a plain array of movies - exactly the shape it was before pagination existed - while X-Pagination carries the counts alongside it. Access-Control-Expose-Headers is the detail that's easy to miss and breaks everything silently if skipped: by default, browsers hide all but a small allow-list of response headers from JavaScript on a cross-origin request, custom headers included, unless the server explicitly exposes them.

Wiring the React Pagination component to actual state

The page number and page size live in component state and drive a re-fetch whenever either changes:

const [paginationRequestParams, setPaginationRequestParams] = useState<PaginationRequestParams>({
  pageSize: pageSize,
  pageNumber: currentPage,
})

useEffect(() => {
  fetchData()
}, [paginationRequestParams])

const handlePageNumberChange = (_: React.MouseEvent<HTMLAnchorElement>, data: any) => {
  setPaginationRequestParams({ ...paginationRequestParams, pageNumber: data.activePage })
}

Semantic UI's Pagination component just needs activePage and totalPages to render the page control - the click handler updates state, the useEffect dependency on paginationRequestParams triggers the re-fetch, and the API call carries the new page number as a query parameter:

getMovies: async (params: PaginationRequestParams) => {
  const response = await axiosInstance.get(
    `/movies?pageSize=${params.pageSize}&pageNumber=${params.pageNumber}`
  )
  // ...
}

A page-size Dropdown next to it follows the identical pattern - changing it updates the same state object, which re-triggers the same fetch, with the API's MaximumPageSize clamp as the backstop regardless of what the client sends.

Common pitfalls

  • Fetching the whole table and paginating client-side. Defeats the entire purpose - the payload size problem happens before pagination ever gets a chance to help.
  • Not clamping page size server-side. A client controlling how much data it can pull back in one request is a resource-exhaustion risk waiting to happen.
  • Forgetting Access-Control-Expose-Headers. The pagination metadata header gets silently dropped from what JavaScript can see on a cross-origin request without it.
  • Running Skip/Take after materializing the query with .ToList(). That pages in memory, not in SQL - the entire table still gets loaded first.
  • Counting the already-paginated result instead of the full filtered set when computing total pages.

Key Takeaways

  • Clamp page size server-side regardless of what the client requests - never trust it as an unbounded input.
  • Skip/Take on an IQueryable<T> translates into OFFSET/FETCH in SQL, so only the requested page is actually pulled from the database.
  • Pagination metadata (page size, current page, totals) travels in a custom response header, keeping the response body's shape unchanged.
  • Access-Control-Expose-Headers must explicitly list any custom header for cross-origin JavaScript to read it.
  • React state for page number and page size, combined with a useEffect dependency on that state, is the standard pattern for driving a re-fetch on pagination changes.

Get the Full Source Code

The complete runnable solution - the pagination helper, the header-based response wrapper, and the React pagination + page-size controls - is available to Patreon supporters. If you want the pattern ready to apply to your own paginated endpoints instead of rebuilding it from the walkthrough above, you can find it on Patreon.