12. Pagination
About this chapter
In this chapter we'll implement pagination, allowing clients to:
- Break resource collection requests into multiple pages
- As opposed to returning every resource in the collection
- Define the size of the page
- Move forwards and backwards through the pages of results
Learning outcomes
- Understand what pagination is
- Understand Why it's important to implement pagination
- Understand how to implement pagination in a .NET REST API
Architecture Checkpoint
In reference to our solution architecture, we'll be making code changes to the highlighted components in this chapter:
- Controllers (partially complete)
- DTOs (partially complete)
- Repository (partially complete)

- The code for this section can be found here on GitHub
- The complete finished code can be found here on GitHub
Feature branch
Ensure that main is current, then create a feature branch called: chapter_12_pagination, and check it out:
git branch chapter_12_pagination
git checkout chapter_12_pagination
If you can't remember the full workflow, refer back to Chapter 5
What is Pagination?
We have the following endpoints that return collections of resources:
- PlatformsController:
GetPlatforms - CommandsController:
GetCommands - CommandsController:
GetCommandsForPlatform
Unless you've added a significant amount of Platform or Command data, these endpoints will only return small numbers of each.
What happens when that number increases, say to 100, 1,000, 10,000 or even more?
If we have 30,000 commands in our database, then every time we call GetCommands all 30,000 Command resources will be returned. This will impact (negatively) responses times of the API, as well as the size of the payloads over the network.
There needs to be a better way. Enter Pagination.
With pagination we:
- Introduce the concept of a page size. This defines the maximum number of resources that can be returned with a single call.
- The page size is either passed by the API consumer with each call, or if omitted, will default to a system-defined value, (we'll use 10 in our code).
- If users do choose to supply a page size with their requests, it cannot exceed the system-defined maximum, (we'll use 100 in our code).
- If the overall number of resources to be returned exceeds the page size, then the API consumer can request subsequent pages of data by supplying either a cursor or page number with each subsequent request.
Implementing pagination
We have to make additions and updates to introduce pagination to our API, we'll start with defining a new set of DTOs tasked with modelling paginated API responses.
DTOs
Create a new file in the DTOs folder called PaginatedList.cs and add the following code:
namespace CommandAPI.Dtos;
public class PaginatedList<T>
{
public IList<T> Items { get; set; }
public int PageIndex { get; set; }
public int PageSize { get; set; }
public int TotalCount { get; set; }
public int TotalPages => (TotalCount + PageSize - 1) / PageSize;
public bool HasPreviousPage => PageIndex > 1;
public bool HasNextPage => PageIndex < TotalPages;
public PaginatedList()
{
Items = new List<T>();
}
public PaginatedList(IList<T> items, int count, int pageIndex, int pageSize)
{
PageIndex = pageIndex;
PageSize = pageSize;
TotalCount = count;
Items = items;
}
}
This code:
- Defines a generic
PaginatedList<T>class that wraps any collection of items for paginated API responses- Generics is covered in the theory section but they essentially allow us to create templates that get filled with a specific type when required
- Contains
Itemsproperty holding the actual page of data being returned - Stores pagination metadata:
PageIndex(current page number),PageSize(items per page), andTotalCount(total items across all pages) - Calculates
TotalPagesusing ceiling division:(TotalCount + PageSize - 1) / PageSizeto ensure partial pages are counted - Provides
HasPreviousPageandHasNextPageboolean properties to help API consumers determine navigation options - Offers a parameterized constructor for creating fully populated paginated responses
- Includes a default constructor that initializes an empty list for scenarios where no data is found
Remaining with the DTOs folder, create another file called PaginationParams.cs and add the following code:
using System.ComponentModel.DataAnnotations;
namespace CommandAPI.Dtos;
public record PaginationParams
{
[Range(1, int.MaxValue, ErrorMessage = "PageIndex must be at least 1.")]
public int PageIndex { get; init; } = 1;
[Range(1, 100, ErrorMessage = "PageSize must be between 1 and 100.")]
public int PageSize { get; init; } = 10;
}
This code:
- Defines a
recordtype for pagination parameters that will be passed to controller endpoints - Uses
initproperties making the record immutable after initialization, ensuring pagination parameters can't be accidentally modified - Declares
PageIndexwith a default value of1(first page), allowing API consumers to omit this parameter - Validates that
PageIndexmust be at least1using theRangeattribute, preventing invalid page requests - Declares
PageSizewith a default value of10, providing a sensible default when consumers don't specify a page size - Enforces that
PageSizemust be between1and100, preventing consumers from requesting excessively large pages (our system-defined maximum) - Provides clear validation error messages that will be returned in the API response if constraints are violated
- Can be bound from query string parameters in controller endpoints (e.g.,
?pageIndex=2&pageSize=20)
We'll employ both DTOs when we come to refactor the endpoints in the controllers. However, before we do that, we need to update the repositories to handle paginated requests.
Repositories
As we are implementing pagination, we will now expect to have the following additional parameters passed when querying collections of resources:
pageSizepageIndex
To that end, we need to refactor the relevant methods in the repositories to handle this information.
Platforms Repository
Open IPlatformRepository.cs and update the method signature for GetPlatformsAsync:
public interface IPlatformRepository
{
Task<bool> SaveChangesAsync();
Task<PaginatedList<Platform>> GetPlatformsAsync(int pageIndex, int pageSize);
Task<Platform?> GetPlatformByIdAsync(int id);
Task CreatePlatformAsync(Platform platform);
Task UpdatePlatformAsync(Platform platform);
void DeletePlatform(Platform platform);
}
This update:
- Requires that a
pageIndexandpageSizeparameters are passed with the request - Returns a
PaginatedListDTO of typePlatform
With this update, any classes implementing this interface (PgSqlPlatformRepository) will now be generating compile time errors, we need to fix that by updating the implementation classes.
Open PgSqlPlatformRepository.cs and add the following using statement at the top:
using CommandAPI.Dtos;
Then update the GetPlatformsAsync method as follows:
public async Task<PaginatedList<Platform>> GetPlatformsAsync(int pageIndex, int pageSize)
{
var platforms = await _context.Platforms
.AsNoTracking()
.OrderBy(c => c.Id)
.Skip((pageIndex -1 ) * pageSize)
.Take(pageSize)
.ToListAsync();
var count = await _context.Platforms.CountAsync();
return new PaginatedList<Platform>(platforms, count, pageIndex, pageSize);
}
This code:
- Expects to be passed values for
pageIndexandpageSize - Uses
AsNoTracking()(as before) to maximize read operations - Orders results by
Idto ensure consistent ordering across paginated requests - Calculates which records to skip using
Skip((pageIndex - 1) * pageSize)to jump to the correct page- For example, page 2 with pageSize 10 skips the first 10 records:
(2 - 1) * 10 = 10
- For example, page 2 with pageSize 10 skips the first 10 records:
- Uses
Take(pageSize)to retrieve only the specified number of records for the current page - Executes the query asynchronously (as before) with
ToListAsync()to fetch the data - Separately queries the total count of all platforms with
CountAsync()for pagination metadata - Returns a
PaginatedList<Platform>containing both the page data and pagination information (count,pageIndex,pageSize)
Commands repository
The code pattern used to introduce pagination to Platform repository will be used again to refactor 2 methods in the Commands repository. So while the code changes will be provided below, to save time and bloat I'll avoid re-explaining the code.
Open ICommandRepository.cs and add the following using statement at the top:
using CommandAPI.Dtos;
Then update the method signatures for GetCommandsAsync and GetCommandsByPlatformIdAsync as follows:
public interface ICommandRepository
{
Task<bool> SaveChangesAsync();
Task<PaginatedList<Command>> GetCommandsAsync(int pageIndex, int pageSize);
Task<Command?> GetCommandByIdAsync(int id);
Task<PaginatedList<Command>> GetCommandsByPlatformIdAsync(int platformId, int pageIndex, int pageSize);
Task CreateCommandAsync(Command command);
Task UpdateCommandAsync(Command command);
void DeleteCommand(Command command);
}
Open PgSqlCommandRepository.cs and add the following using statement at the top:
using CommandAPI.Dtos;
Then update the methods GetCommandsAsync and GetCommandsByPlatformIdAsync as follows:
// .
// .
// Existing code
public async Task<PaginatedList<Command>> GetCommandsAsync(int pageIndex, int pageSize)
{
var commands = await _context.Commands
.AsNoTracking()
.OrderBy(c => c.Id)
.Skip((pageIndex - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
var count = await _context.Commands.CountAsync();
return new PaginatedList<Command>(commands, count, pageIndex, pageSize);
}
// .
// .
// Existing code
// .
// .
public async Task<PaginatedList<Command>> GetCommandsByPlatformIdAsync(int platformId, int pageIndex, int pageSize)
{
var commands = await _context.Commands
.AsNoTracking()
.Where(c => c.PlatformId == platformId)
.OrderBy(c => c.Id)
.Skip((pageIndex - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
var count = await _context.Commands.CountAsync(c => c.PlatformId == platformId);
return new PaginatedList<Command>(commands, count, pageIndex, pageSize);
}
// Existing code
// .
// .
With the repositories updated, we'll complete the puzzle by updating the controllers to handle pagination.
Controllers
Platforms
Open PlatformsController.cs and update GetPlatforms as follows:
[HttpGet]
public async Task<ActionResult<PaginatedList<PlatformReadDto>>> GetPlatforms([FromQuery] PaginationParams pagination)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
var platforms = await _platformRepo.GetPlatformsAsync(pagination.PageIndex, pagination.PageSize);
var platformDtos = platforms.Items.Select(p => p.Adapt<PlatformReadDto>()).ToList();
var result = new PaginatedList<PlatformReadDto>(
platformDtos,
platforms.TotalCount,
platforms.PageIndex,
platforms.PageSize
);
return Ok(result);
}
This code:
- Accepts (from the query string) a
PaginatedParametersDTO (pagination) that contains apageSizeand apageIndex - Performs a validation check on the
paginationDTO to ensurepageSizeandpageIndexadhere to the defined data annotations - Uses the refactored repository method
GetPlatformsAsyncand passes the values ofpageIndexandpageSize - Maps the models returned from the repository to a list of
PlatformReadDtos - Constructs a
resultwhich is aPaginatedListresponse ofPlatformReadDtos, along with the following metadata:TotalCount,PageIndexandPageSize - Returns the result as before.
Remaining within the Platforms controller update GetCommandsForPlatform as follows:
[HttpGet("{platformId}/commands")]
public async Task<ActionResult<PaginatedList<CommandReadDto>>> GetCommandsForPlatform(int platformId, [FromQuery] PaginationParams pagination)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
var platform = await _platformRepo.GetPlatformByIdAsync(platformId);
if (platform == null)
return NotFound();
var commands = await _commandRepo.GetCommandsByPlatformIdAsync(platformId, pagination.PageIndex, pagination.PageSize);
var commandDtos = commands.Items.Select(c => c.Adapt<CommandReadDto>()).ToList();
var result = new PaginatedList<CommandReadDto>(
commandDtos,
commands.TotalCount,
commands.PageIndex,
commands.PageSize
);
return Ok(result);
}
As I've already described the pagination code changes for GetPlatforms for brevity I won't repeat the same information for GetCommandsForPlatform.
Commands
Open CommandsController.cs, and update GetCommands as follows:
[HttpGet]
public async Task<ActionResult<PaginatedList<CommandReadDto>>> GetCommands([FromQuery] PaginationParams pagination)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
var commands = await _commandRepo.GetCommandsAsync(pagination.PageIndex, pagination.PageSize);
var commandDtos = commands.Items.Select(c => c.Adapt<CommandReadDto>()).ToList();
var result = new PaginatedList<CommandReadDto>(
commandDtos,
commands.TotalCount,
commands.PageIndex,
commands.PageSize
);
return Ok(result);
}
Ensure you have saved everything, build the project to ensure there are no errors:
dotnet build
Exercising changes
You should be able to re-run existing requests without changing the calls, e.g. I can re-run the following request:
### Get all platforms
GET {{baseUrl}}/api/platforms
And get the following response:
HTTP/1.1 200 OK
Connection: close
Content-Type: application/json; charset=utf-8
Date: Tue, 10 Feb 2026 14:10:13 GMT
Server: Kestrel
Transfer-Encoding: chunked
{
"items": [
{
"id": 1,
"platformName": "K8",
"createdAt": "0001-01-01T00:00:00"
},
{
"id": 2,
"platformName": "Word",
"createdAt": "0001-01-01T00:00:00"
},
{
"id": 3,
"platformName": "Excel",
"createdAt": "2026-01-12T10:21:08.850105Z"
},
{
"id": 4,
"platformName": "Podman",
"createdAt": "2026-01-12T10:38:54.56966Z"
},
{
"id": 6,
"platformName": "Huginn",
"createdAt": "2026-01-28T13:32:36.726638Z"
}
],
"pageIndex": 1,
"pageSize": 10,
"totalCount": 5,
"totalPages": 1,
"hasPreviousPage": false,
"hasNextPage": false
}
You'll notice that the response now contains the pagination metadata, with the default values for pageIndex and pageSize applied:
"pageIndex": 1,
"pageSize": 10,
"totalCount": 5,
"totalPages": 1,
"hasPreviousPage": false,
"hasNextPage": false
In this instance we only have 1 page of data as I only have 5 resources (totalCount) with a pageSize of 10. We can exercise pagination further with the following request:
### Get platforms with pagination
GET {{baseUrl}}/api/platforms?pageIndex=1&pageSize=3
Executing this would return the following in my case:
HTTP/1.1 200 OK
Connection: close
Content-Type: application/json; charset=utf-8
Date: Tue, 10 Feb 2026 14:39:40 GMT
Server: Kestrel
Transfer-Encoding: chunked
{
"items": [
{
"id": 1,
"platformName": "K8",
"createdAt": "0001-01-01T00:00:00"
},
{
"id": 2,
"platformName": "Word",
"createdAt": "0001-01-01T00:00:00"
},
{
"id": 3,
"platformName": "Excel",
"createdAt": "2026-01-12T10:21:08.850105Z"
}
],
"pageIndex": 1,
"pageSize": 3,
"totalCount": 5,
"totalPages": 2,
"hasPreviousPage": false,
"hasNextPage": true
}
Looking at the hasNextPage property and totalPages property we can see that we have a 2nd page to retrieve, we can do this as follows buy updating the pageIndex property in the request string:
### Get platforms with pagination
GET {{baseUrl}}/api/platforms?pageIndex=2&pageSize=3
This would return:
HTTP/1.1 200 OK
Connection: close
Content-Type: application/json; charset=utf-8
Date: Tue, 10 Feb 2026 14:42:09 GMT
Server: Kestrel
Transfer-Encoding: chunked
{
"items": [
{
"id": 4,
"platformName": "Podman",
"createdAt": "2026-01-12T10:38:54.56966Z"
},
{
"id": 6,
"platformName": "Huginn",
"createdAt": "2026-01-28T13:32:36.726638Z"
}
],
"pageIndex": 2,
"pageSize": 3,
"totalCount": 5,
"totalPages": 2,
"hasPreviousPage": true,
"hasNextPage": false
Remember to exercise the other endpoints that return collections of resources by appending ?pageIndex=1&pageSize=20 to your requests,
Version Control
With the code complete, it's time to commit our code. A summary of those steps can be found below, for a more detailed overview refer to Chapter 5
- Save all files
git add .git commit -m "add pagination to all resource list endpoints"git push(will fail - copy suggestion)git push --set-upstream origin chapter_12_pagination- Move to GitHub and complete the PR process through to merging
- Back at a command prompt:
git checkout main git pull
Conclusion
In this chapter we implemented pagination across all collection endpoints in our API, addressing a performance concern that emerges as datasets grow. By introducing pagination, we've transformed endpoints that could potentially return thousands of records into controllable, efficient operations that return manageable pages of data.
Key takeaways:
- PaginatedList provides a generic wrapper for paginated responses, containing both the data and essential metadata (
pageSize,pageIndex,totalCount,totalPages,hasNextPage/hasPreviousPage) - PaginationParams record encapsulates pagination parameters with built-in validation, enforcing sensible defaults (page 1, size 10) and system-defined limits (max 100 items per page)
- Repository layer changes use Entity Framework's
Skip()andTake()methods to retrieve only the requested page of data. - Consistent ordering is crucial for pagination: we order by
Idto ensure users get predictable, non-overlapping results across page requests - Validation ensures API consumers can't request invalid pages (page 0) or excessively large page sizes that could overwhelm the system
The pagination pattern we've implemented is straightforward and effective for most scenarios. API consumers can now navigate through large datasets efficiently by appending query parameters like ?pageIndex=2&pageSize=20 to their requests.
Without pagination, endpoints returning large collections become performance bottlenecks resulting in a number of negative outcomes, not least poor user experience. With pagination in place, our API can scale to handle datasets of any size while maintaining performance.
In the next chapter, we'll continue enhancing our collection endpoints by adding filtering and sorting capabilities, giving API consumers even more control over the data they retrieve.