Backend guides
Pagination
Return paged, sortable list results with the shared pagination primitives, from query to typed client.
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 thePageSizeceiling 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/pageSizefrom the query string and maps to the wirePagedResponse<T>. - Frontend type mirrors the wire shape; page and filters live in the TanStack query key.
- Verify:
?page=2&pageSize=20returns the next slice, and?pageSize=10000is rejected with HTTP 400.