Skip to content

.NET Pagination in Nutshell

Posted on:November 11, 2023 at 03:00 PM

net-pagination-in-nutshell Photo by Kelly Sikkema on Unsplash

Table of Contents

Open Table of Contents

What is Pagination

With pagination, we divide a large set of content or data into separate pages to make it more accessible and manageable for users. Instead of displaying all the content on a single page, we combine it into smaller chunks with pagination.

How to implement it

We will get pagination information from query. For example: http://.../resource?pageIndex=1&pageSize=30

We should limit the page size. If it exceeds the limit we should return BadRequest. (We can also automatically equalize the page size to the limit)

We should consider pagination as default. Data may grow unexpectedly.

If the pageIndexor pageSize is not specified in the query we should return with default values. (For example, 1 for pageIndex and 10 for pageSize ).

We should return metadata with pagination result. Generally, this pagination result involves:

We will use the response header to put pagination metadata. For example X-Pagination

We can use Skip and Takefunctions for pagination.

Implementation

Create the project

dotnet new webapi -n PaginationExample

Install in memory db provider for testing

dotnet add package Microsoft.EntityFrameworkCore.InMemory

Create Models

Very basic Item model for testing pagination:

public class Item
{
    public required int Id { get; set; }
    public required string Name { get; set; }
}

PaginationQueryParameters is a generic class for routes that use pagination:

public record PaginationQueryParameters(int PageIndex = 1, int PageSize = 10);

PaginatedHttpGetAttribute is helper attribute we will use. With this attribute, we force the developer to give a route name to pass HttpGetattribute. This route name is used by IUrlHelper.Link method to create previous and next page links in pagination metadata.

public class PaginatedHttpGetAttribute : HttpGetAttribute
{
    public PaginatedHttpGetAttribute(string name)
    {
        Name = name;
    }
}

PagedListis the most important model that handles pagination logic.

public class PagedList<T> : List<T>
{
    public int PageSize { get; private set; }
    public int CurrentPage { get; private set; }
    public int TotalItemCount { get; private set; }
    public int TotalPageCount { get; private set; }
    public bool HasPrevious => (CurrentPage > 1);
    public bool HasNext => (CurrentPage < TotalPageCount);

    private PagedList(List<T> items, int pageSize, int currentPage, int totalItemCount)
    {
        PageSize = pageSize;
        CurrentPage = currentPage;
        TotalItemCount = totalItemCount;
        TotalPageCount = (int)Math.Ceiling(TotalItemCount / (double)PageSize);
        AddRange(items);
    }

    public static async Task<PagedList<T>> CreateAsync(IQueryable<T> items, int pageIndex, int pageSize)
    {
        var count = await items.CountAsync();
		// PAGINATION LOGIC HERE, Skip and Take !!
        var pagedItems = await items.Skip(pageSize * (pageIndex - 1)).Take(pageSize).ToListAsync();
        return new PagedList<T>(pagedItems, pageSize, pageIndex, count);
    }
}

DbContext & Repository

Add DbContext with initial data seed for testing pagination. We will test pagination using in-memory database.

public class ItemDbContext : DbContext
{
   public DbSet<Item> Items { get; set; }

   public ItemDbContext(DbContextOptions<ItemDbContext> options) : base(options)
   {
      Database.EnsureCreated();
   }

   protected override void OnModelCreating(ModelBuilder builder)
   {
	   // Seed database with 100 items.
      var items = Enumerable.Range(1, 100).Select(index => new Item
      {
         Id = index,
         Name = $"Item at index = {index}"
      }).ToList();
      builder.Entity<Item>().HasData(items);
      base.OnModelCreating(builder);
   }
}

A basic repository that references our PagedList model.

public class ItemRepository
{
    private readonly ItemDbContext _context;

    public ItemRepository(ItemDbContext context)
    {
        _context = context;
    }

    public async Task<PagedList<Item>> GetItems(int pageIndex, int pageSize)
    {
        return await PagedList<Item>.CreateAsync(_context.Items, pageIndex, pageSize);
	}
}

Don’t forget the inject them at the composition root Program.cs:

builder.Services.AddDbContext<ItemDbContext>(opt =>
{
    opt.UseInMemoryDatabase("PaginationTestDB");
});

builder.Services.AddScoped<ItemRepository>();

Controller and Extension Method

We will create an extension method for adding pagination metadata to response headers.

public static class AddPaginationMetadataExtension
{
    public static void AddPaginationMetadata<T>(this ControllerBase controller, PagedList<T> pagedItems,
        PaginationQueryParameters queryParameters)
    {
        string? previousPageUrl = null;
        string? nextPageUrl = null;

        var paginationAttribute = (PaginatedHttpGetAttribute?)controller.ControllerContext.ActionDescriptor.MethodInfo
            .GetCustomAttributes(false).FirstOrDefault(obj => obj is PaginatedHttpGetAttribute);

        Debug.Assert(paginationAttribute is not null,
            "You should define PaginatedHttpGet attribute in paginated actions.");

        var routeName = paginationAttribute.Name;

        if (pagedItems.HasPrevious)
        {
			// If we have previous page, include link to that page
            previousPageUrl = controller.Url.Link(routeName, queryParameters with
            {
                PageIndex = queryParameters.PageIndex - 1
            });
        }

        if (pagedItems.HasNext)
        {
			// If we have next page include, link to that page
            nextPageUrl = controller.Url.Link(routeName, queryParameters with
            {
                PageIndex = queryParameters.PageIndex + 1
            });
        }

		// Add header
        controller.Response.Headers.Add("X-Pagination", JsonSerializer.Serialize(
            new
            {
                pagedItems.HasNext,
                pagedItems.HasPrevious,
                pagedItems.TotalPageCount,
                pagedItems.TotalItemCount,
                pagedItems.CurrentPage,
                pagedItems.PageSize,
                previousPageUrl,
                nextPageUrl
            }));
    }
}

And finally, add our paginated ItemController:

[ApiController]
[Route("[controller]")]
public class ItemController : ControllerBase
{
    private readonly ItemRepository _itemRepository;

    public ItemController(ItemRepository itemRepository)
    {
        _itemRepository = itemRepository;
    }

    [PaginatedHttpGet("GetItems")]  // This attribute is required!
    public async Task<ActionResult<List<Item>>> GetItems([FromQuery] PaginationQueryParameters paginationData)
    {
		// Should valiidate maximum pageSize !
		// Get items with pagination
        var items = await _itemRepository.GetItems(paginationData.PageIndex, paginationData.PageSize);

		// Add pagination metadata to headers
        this.AddPaginationMetadata(items, paginationData);

        return items.ToList();
    }
}

Conclusion

We implemented simple pagination with pagination metadata. You can use the SwaggerUI to test it (http://localhost:xxxx/swagger/index.html). Further improvements can be made to the PaginatedHttpGetAttribute and AddPaginationMetadataExtension and we should add validation for query parameters.

Example Query

curl -X 'GET' 'http://localhost:5209/Item?PageIndex=2&PageSize=2' -H 'accept: text/plain'

Response body

[
  {
    "id": 3,
    "name": "Item at index = 3"
  },
  {
    "id": 4,
    "name": "Item at index = 4"
  }
]

Response header (x-pagination)

{
  "HasNext": true,
  "HasPrevious": true,
  "TotalPageCount": 50,
  "TotalItemCount": 100,
  "CurrentPage": 2,
  "PageSize": 2,
  "previousPageUrl": "http://localhost:5209/Item?PageIndex=1&PageSize=2",
  "nextPageUrl": "http://localhost:5209/Item?PageIndex=3&PageSize=2"
}

Thanks for reading. For source code: GitHub