[](https://www.nuget.org/packages/Swevo.EFCore.Pagination)
Offset and cursor-based pagination for EF Core. Two extension methods on IQueryable<T>, two result types — nothing else.
// Offset pagination
var page = await db.Orders
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.CreatedAt)
.ToPageAsync(pageNumber: 1, pageSize: 20);
// page.Items IReadOnlyList<Order>
// page.TotalCount int
// page.TotalPages int
// page.HasNextPage bool
// page.HasPreviousPage bool
// Cursor (keyset) pagination
var page = await db.Orders
.Where(o => o.CustomerId == customerId)
.OrderBy(o => o.Id)
.ToCursorPageAsync(o => o.Id, afterCursor: cursor, pageSize: 20);
// page.Items IReadOnlyList<Order>
// page.NextCursor string? — pass to the next request
// page.HasNextPage booldotnet add package Swevo.EFCore.PaginationOffset (ToPageAsync) |
Cursor (ToCursorPageAsync) |
|
|---|---|---|
| Jump to page | ✅ | ❌ |
| Total count | ✅ | ❌ |
| Consistent under inserts | ❌ | ✅ |
| Scalable to millions of rows | ❌ | ✅ |
| Single query | ❌ (count + fetch) | ✅ |
Use offset for admin grids with page numbers. Use cursor for infinite-scroll feeds and APIs.
// GET /orders?page=2&pageSize=20
var page = await db.Orders
.OrderByDescending(o => o.CreatedAt)
.ToPageAsync(pageNumber: 2, pageSize: 20, cancellationToken);
return new
{
items = page.Items,
totalCount = page.TotalCount,
totalPages = page.TotalPages,
page = page.PageNumber,
pageSize = page.PageSize,
hasPrev = page.HasPreviousPage,
hasNext = page.HasNextPage,
};pageNumberis 1-based.- Issues two SQL queries:
COUNT(*)thenSELECT ... SKIP ... TAKE. pageSizeis capped at 1000.
// GET /orders?cursor=eyJJZCI6MTAwfQ==&pageSize=20
var page = await db.Orders
.OrderBy(o => o.Id) // MUST be ordered by the cursor key
.ToCursorPageAsync(
keySelector: o => o.Id,
afterCursor: Request.Query["cursor"], // null on first page
pageSize: 20,
cancellationToken);
return new
{
items = page.Items,
nextCursor = page.NextCursor, // null on last page
hasNext = page.HasNextPage,
};- Issues a single
SELECT ... WHERE id > ? LIMIT pageSize + 1. - Cursor is an opaque Base64-encoded JSON token — do not parse it.
- Supported key types:
int,long,Guid,DateTime,DateTimeOffset, and any value type with a>SQL operator.
string? cursor = null;
do
{
var page = await db.Products
.OrderBy(p => p.Id)
.ToCursorPageAsync(p => p.Id, afterCursor: cursor, pageSize: 100);
await ProcessBatchAsync(page.Items);
cursor = page.NextCursor;
} while (cursor is not null);Stack with the other Swevo EF Core packages:
[Auditable] // → CreatedAt, UpdatedAt
[SoftDelete] // → IsDeleted, global query filter
public partial class Order
{
public OrderId Id { get; set; } // → Swevo.EFCore.StronglyTyped
}
// Paginate with cursor
var page = await db.Orders
.OrderBy(o => o.Id)
.ToCursorPageAsync(o => o.Id, afterCursor: cursor, pageSize: 20);| Package | Purpose |
|---|---|
| Swevo.EFCore.Pagination | This package |
| Swevo.EFCore.StronglyTyped | Strongly-typed IDs |
| Swevo.AutoAudit | Audit fields |
| Swevo.EFCore.SoftDelete | Soft delete |
| Swevo.EFCore.Outbox | Transactional outbox |
🌐 Full suite overview: swevo.github.io
| Package | Description |
|---|---|
| AutoLog.Generator | Compile-time high-performance logging — [Log(Level, Message)] generates LoggerMessage.Define. AOT-safe. |
| AutoHttpClient.Generator | Compile-time typed HTTP client — [HttpClient] on an interface generates a strongly-typed client. AOT-safe Refit alternative. |
| AutoDispatch.Generator | Compile-time CQRS dispatcher — [Handler] generates a strongly-typed IDispatcher. No MediatR, no reflection. |
| AutoWire | Compile-time DI auto-registration — [Scoped]/[Singleton]/[Transient] generates IServiceCollection registration code. |
| AutoMap.Generator | Compile-time object mapping with generated extension methods. AOT-safe AutoMapper alternative. |
MIT © 2026 Justin Bannister