Skip to content
Slicekit

Backend guides

Pagination

Return paged, sortable list results with the shared pagination primitives, from query to typed client.

View .md
On this page

List endpoints share one shape. A query inherits PagedRequest, the handler returns PagedResult<T>, the endpoint maps it to a wire PagedResponse<T>, and the typed client reads that same shape back. The primitives already ship in Slicekit.Core.Common and Slicekit.Api.Common. Reuse them. Do not invent a per-endpoint envelope.

The canonical example is the admin users list: api/src/Slicekit.Core/Features/Users/ListUsers/ for the slice and api/src/Slicekit.Api/Endpoints/v1/Admin/ListUsersEndpoint.cs for the HTTP adapter. This guide follows that slice end to end. For the slice mechanics in general, see vertical slices.

The request: PagedRequest

PagedRequest (in api/src/Slicekit.Core/Common/PagedResult.cs) is an abstract record carrying only init-only Page and PageSize, defaulting to 1 and 20:

public abstract record PagedRequest
{
    public int Page { get; init; } = 1;
    public int PageSize { get; init; } = 20;
}

Your query inherits it and declares only the filter and sort fields of its own slice. ListUsersQuery adds a search term, a sort field, a direction and an enabled filter:

public sealed record ListUsersQuery(
    string? Search = null,
    UserSortField Sort = UserSortField.CreatedAtUtc,
    SortDirection Direction = SortDirection.Desc,
    bool? Enabled = null) : PagedRequest;

Page and PageSize are inherited, so callers set them with object-initializer syntax rather than through the constructor:

new ListUsersQuery(search) { Page = 2, PageSize = 50 }

The validator: PagedRequestValidator

Inherit PagedRequestValidator<T> and you get the page bounds for free:

public abstract class PagedRequestValidator<T> : AbstractValidator<T> where T : PagedRequest
{
    protected PagedRequestValidator()
    {
        RuleFor(x => x.Page).GreaterThanOrEqualTo(1);
        RuleFor(x => x.PageSize).InclusiveBetween(1, 100);
    }
}

The PageSize ceiling of 100 is deliberate: without it a caller can ask for a million rows and time out the database. If a slice has no filter rules of its own, drop the body with a primary-constructor declaration, exactly as ListUsers does:

public sealed class ListUsersQueryValidator : PagedRequestValidator<ListUsersQuery>;

If a slice does need filter rules, add them in the constructor. To raise the ceiling for one endpoint, re-declare RuleFor(x => x.PageSize) in the derived constructor; FluentValidation applies last rule wins for the same property.

The handler: PagedResult

PagedResult<T> is the handler return type. The total-page and navigation flags are computed properties on the record, so the handler never works them out inline:

public sealed record PagedResult<T>(
    IReadOnlyList<T> Items,
    int TotalCount,
    int Page,
    int PageSize)
{
    public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
    public bool HasNextPage => Page < TotalPages;
    public bool HasPreviousPage => Page > 1;
}

The handler filters once, counts, orders, then takes the page. Ordering is mandatory:

public async Task<Result<PagedResult<UserListItem>>> HandleAsync(
    ListUsersQuery query, CancellationToken ct = default)
{
    var q = db.Users.AsNoTracking();

    if (!string.IsNullOrWhiteSpace(query.Search))
    {
        var search = query.Search.Trim();
        if (Guid.TryParse(search, out var id))
            q = q.Where(u => u.Id == id);
        else
            q = q.Where(u => u.Email!.Contains(search));
    }

    if (query.Enabled is { } enabled)
        q = q.Where(u => u.Enabled == enabled);

    var totalCount = await q.CountAsync(ct);

    q = ApplyOrder(q, query.Sort, query.Direction);

    var items = await q
        .Skip((query.Page - 1) * query.PageSize)
        .Take(query.PageSize)
        .Select(u => new UserListItem(u.Id, u.Email!, u.Enabled, u.CreatedAtUtc, u.LastLoginAtUtc))
        .ToListAsync(ct);

    return new PagedResult<UserListItem>(items, totalCount, query.Page, query.PageSize);
}

Two round-trips (one CountAsync, one page query) is the right default. Note that CountAsync runs on the filtered query but before ApplyOrder: counting does not need an ORDER BY, and skipping it keeps Postgres from doing a needless sort.

Why ordering is not optional

Postgres returns rows in an unspecified order without an ORDER BY, so the same page request can return different rows across calls and pages can overlap. ListUsers resolves the sort field and direction to a deterministic key with a switch:

private static IQueryable<User> ApplyOrder(IQueryable<User> q, UserSortField sort, SortDirection dir) =>
    (sort, dir) switch
    {
        (UserSortField.Email, SortDirection.Asc) => q.OrderBy(u => u.Email),
        (UserSortField.Email, SortDirection.Desc) => q.OrderByDescending(u => u.Email),
        (UserSortField.LastLoginAtUtc, SortDirection.Asc) => q.OrderBy(u => u.LastLoginAtUtc),
        (UserSortField.LastLoginAtUtc, SortDirection.Desc) => q.OrderByDescending(u => u.LastLoginAtUtc),
        (_, SortDirection.Asc) => q.OrderBy(u => u.CreatedAtUtc),
        _ => q.OrderByDescending(u => u.CreatedAtUtc),
    };

Default to CreatedAtUtc and add a tie-breaker on Id if duplicate timestamps are possible. Exposing sort fields as an enum rather than a raw column name keeps the surface closed: a caller can only sort by what you list.

The endpoint: bind and map

The endpoint binds page, pageSize and the filters straight from the query string, dispatches the query over Wolverine, and maps the Core list item to a public DTO:

private static async Task<Results<Ok<PagedResponse<UserItem>>, ProblemHttpResult>> HandleAsync(
    int page = 1,
    int pageSize = 20,
    string? search = null,
    UserSortField sort = UserSortField.CreatedAtUtc,
    SortDirection direction = SortDirection.Desc,
    bool? enabled = null,
    IMessageBus bus = default!,
    CancellationToken ct = default)
{
    var result = await bus.InvokeAsync<Result<PagedResult<UserListItem>>>(
        new ListUsersQuery(search, sort, direction, enabled) { Page = page, PageSize = pageSize }, ct);
    if (!result.IsSuccess) return result.Error.ToProblem();

    var paged = result.Value;
    var items = paged.Items
        .Select(u => new UserItem(u.Id, u.Email, u.Enabled, u.CreatedAtUtc, u.LastLoginAtUtc))
        .ToArray();
    return TypedResults.Ok(new PagedResponse<UserItem>(items, paged.TotalCount, paged.Page,
        paged.PageSize, paged.TotalPages));
}

The shared Slicekit.Api.Common.PagedResponse<T> carries the navigation flags and offers a PagedResponse<T>.From(pagedResult) factory so an endpoint never recomputes them by hand. When the Core list-item shape is already the wire shape, call From directly instead of projecting. The admin endpoint defines its own narrower PagedResponse<T> carrying only TotalPages, because that is all its client reads. Use whichever matches the contract your client consumes; do not return more fields than the SPA uses.

The typed client consumes the same shape

On the frontend the wire shape is mirrored in frontend/src/shared/api/types.ts, one to one with what the admin endpoint returns:

export type PagedResponse<T> = {
  items: T[];
  totalCount: number;
  page: number;
  pageSize: number;
  totalPages: number;
};

The client method builds the query string and hands the generic to apiFetch, which returns the parsed shape typed (see the API client):

listUsers: (
  page = 1,
  pageSize = 20,
  search?: string,
  sort: UserSortField = 'CreatedAtUtc',
  direction: SortDirection = 'Desc',
  enabled?: boolean,
) => {
  const params = new URLSearchParams({
    page: String(page),
    pageSize: String(pageSize),
    sort,
    direction,
  });
  if (search) params.set('search', search);
  if (enabled !== undefined) params.set('enabled', String(enabled));
  return apiFetch<PagedResponse<AdminUserListItem>>(`/api/v1/admin/users?${params}`);
},

A TanStack useQuery wraps that call. The page and filter values belong in the query key so each page is cached separately, and placeholderData: (prev) => prev keeps the previous page visible while the next one loads instead of flashing empty:

export function useAdminUsers(
  page = 1,
  pageSize = 20,
  search?: string,
  sort: UserSortField = 'CreatedAtUtc',
  direction: SortDirection = 'Desc',
  enabled?: boolean,
) {
  return useQuery({
    queryKey: [...adminUsersQueryKey, page, pageSize, search ?? '', sort, direction, enabled ?? 'all'],
    queryFn: () => adminApi.listUsers(page, pageSize, search, sort, direction, enabled),
    placeholderData: (prev) => prev,
  });
}

The component reads data.items for the rows and data.totalPages (or data.totalCount) to drive the pager.

When offset paging is not enough

Skip(n) grows expensive as n grows, because Postgres still reads and discards the skipped rows. For tables that can grow large or be paged deeply (exports, infinite scroll), reach for cursor-based paging: order by a stable key, return a nextCursor (typically the last Id plus CreatedAtUtc), and have the client send it back. Slicekit ships no generic cursor helper. Add one only when a specific endpoint demonstrably needs it.

Checklist

  • Query inherits PagedRequest; declare only your own filter and sort fields.
  • Validator inherits PagedRequestValidator<T>; keep the PageSize ceiling unless you have a reason.
  • Handler returns PagedResult<T>: filter, CountAsync, order, Skip/Take, project.
  • Ordering is present and deterministic (a stable key, with a tie-breaker if needed).
  • Endpoint binds page/pageSize from the query string and maps to the wire PagedResponse<T>.
  • Frontend type mirrors the wire shape; page and filters live in the TanStack query key.
  • Verify: ?page=2&pageSize=20 returns the next slice, and ?pageSize=10000 is rejected with HTTP 400.