Identity Base: Unified Pagination and Sorting
Published: November 6, 2025
Identity Base 0.6.1 completes the pagination rollout, standardizing pagination, search, and sorting across all list endpoints in both admin and user-scoped APIs. This means you can build reusable data fetching patterns and UI components that work consistently whether you're querying organizations, users, roles, permissions, or organization members.
Before this update, different endpoints handle pagination differently. Some use offset and limit parameters, others use page and pageSize, and a few return unpaginated arrays that require client-side slicing. Sorting varies - available on some endpoints, missing on others, with varying syntax where it exists.
This creates friction during integration. Developers can't write a generic data table component that works across all list endpoints because each endpoint requires different parameter names and response structures. Caching logic must account for different pagination schemes. Testing becomes tedious when you need separate test utilities for each endpoint pattern.
⚠️ Breaking Changes
Query parameter standardization: All list endpoints now use page and pageSize parameters. Endpoints that previously used offset and limit no longer accept those parameters. Update your API calls to use the new parameter names.
Response envelope changed: All list endpoints now return a PagedResult<T> envelope with page, pageSize, total, and items fields. Endpoints that previously returned plain arrays now return this structured envelope. Update your response parsing code accordingly.
Sort syntax standardized: The sort parameter now uses field:direction format (e.g., sort=createdAt:desc,name:asc). Endpoints with different sorting syntax require updates to match the new format.
Pagination now required: Endpoints that previously returned all results unpaginated now enforce pagination limits. If you were relying on fetching all results in one request, implement pagination loops or increase pageSize appropriately.
Unified Request Parameters
Every list endpoint now accepts the same set of query parameters:
page- 1-based page number (defaults to 1)pageSize- items per page (defaults to 20, configurable per endpoint)search- free-text search (behavior varies by resource type)sort- comma-separatedfield:directionpairs
These parameters work identically across admin endpoints (/admin/users, /admin/organizations, /admin/roles) and user-scoped endpoints (/users/me/organizations, /users/me/organizations/{id}/members, /users/me/organizations/{id}/invitations).
Here's a request to list organizations with pagination and search:
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
"https://your-identity-base.example.com/admin/organizations?page=2&pageSize=20&search=acme"
The same parameter structure works for user-scoped organization lists:
curl -H "Authorization: Bearer $ACCESS_TOKEN" \
"https://your-identity-base.example.com/users/me/organizations?page=1&pageSize=50&search=robotics"
Unified Response Envelope
All list endpoints return the same response structure:
{
"page": 1,
"pageSize": 20,
"totalCount": 123,
"items": [
/* array of resources */
]
}
The totalCount field contains the total count of items matching the search filter (not just items on the current page). This lets you calculate total page count for pagination UI without a separate count query.
The items array contains the actual resources. The resource structure varies by endpoint (organizations have different fields than users), but the pagination envelope is identical.
This consistency means you can write generic response handling code:
interface PagedResponse<T> {
page: number;
pageSize: number;
totalCount: number;
items: T[];
}
async function fetchPaged<T>(
endpoint: string,
page: number,
pageSize: number
): Promise<PagedResponse<T>> {
const response = await fetch(
`${endpoint}?page=${page}&pageSize=${pageSize}`,
{ headers: { 'Authorization': `Bearer ${token}` } }
);
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.statusText}`);
}
return response.json();
}
// Works for any list endpoint
const orgs = await fetchPaged<Organization>('/admin/organizations', 1, 20);
const users = await fetchPaged<User>('/admin/users', 1, 20);
const members = await fetchPaged<Member>('/users/me/organizations/org_abc/members', 1, 50);
Search Behavior by Resource Type
The search parameter performs free-text searching, but what fields it searches varies by resource type. This is intentional - different resources have different fields that make sense to search.
Organizations search by name. A search for "acme" matches organizations with "acme" anywhere in their name (case-insensitive).
Users search by email and username. A search for "sarah" matches users with "sarah" in their email address or username.
Roles search by role name and description. A search for "admin" matches roles named "OrgAdmin", "PlatformAdmin", or roles with "administrator" in their description.
Permissions search by permission string and description. A search for "organizations" matches permissions like "user.organizations.write" or "admin.organizations.read".
Organization Members search by user email and username (same as user search, but scoped to organization membership).
The search is always combined with pagination - the totalCount field reflects items matching the search, and items contains only the current page of results.
# Search for organizations containing "robotics", return page 1
curl -H "Authorization: Bearer $TOKEN" \
"https://your-identity-base.example.com/users/me/organizations?search=robotics&page=1&pageSize=20"
Sorting with Multiple Fields
The sort parameter accepts comma-separated field-direction pairs. Direction is either asc (ascending) or desc (descending). Multiple sort fields are applied left-to-right, with earlier fields taking precedence.
# Sort by createdAt descending, then by name ascending
curl -H "Authorization: Bearer $TOKEN" \
"https://your-identity-base.example.com/admin/organizations?sort=createdAt:desc,name:asc"
This returns organizations newest-first, with ties broken alphabetically by name.
Sortable fields vary by resource type. Common patterns:
Organizations: name, createdAt, membersCount
Users: email, username, createdAt, lastLoginAt
Roles: name, createdAt
Permissions: name, createdAt
Organization Members: email, role, joinedAt
If you specify an invalid sort field, the endpoint returns 400 Bad Request with an error message listing valid fields. This helps during development - you discover sorting capabilities through error messages rather than hunting through documentation.
Sort order affects only the items in the response. The totalCount is the same regardless of sort order (it reflects search filtering, not sorting).
Building Reusable List Components
The unified contracts enable building generic list components that work across all endpoints. Here's a React example using TanStack Query:
interface UsePagedListOptions {
endpoint: string;
page: number;
pageSize: number;
search?: string;
sort?: string;
orgId?: string; // Optional org context
}
function usePagedList<T>(options: UsePagedListOptions) {
const { endpoint, page, pageSize, search, sort, orgId } = options;
return useQuery({
queryKey: ['paged-list', endpoint, page, pageSize, search, sort, orgId],
queryFn: async () => {
const params = new URLSearchParams({
page: page.toString(),
pageSize: pageSize.toString(),
...(search && { search }),
...(sort && { sort })
});
const headers: Record<string, string> = {
'Authorization': `Bearer ${getAccessToken()}`
};
if (orgId) {
headers['X-Organization-Id'] = orgId;
}
const response = await fetch(`${endpoint}?${params}`, { headers });
if (!response.ok) {
throw new Error(`Request failed: ${response.statusText}`);
}
return response.json() as Promise<PagedResponse<T>>;
}
});
}
// Usage - works for any list endpoint
function OrganizationList() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const { data, isLoading } = usePagedList<Organization>({
endpoint: '/users/me/organizations',
page,
pageSize: 20,
search,
sort: 'createdAt:desc'
});
if (isLoading) return <div>Loading...</div>;
return (
<div>
<SearchInput value={search} onChange={setSearch} />
<DataTable items={data.items} />
<Pagination
current={page}
total={Math.ceil(data.totalCount / data.pageSize)}
onChange={setPage}
/>
</div>
);
}
The same usePagedList hook works for members, roles, invitations, or any other list endpoint. You just change the endpoint URL and type parameter.
Client-Side State Management
A common pattern for list views: store pagination state in the URL query string. This makes list views bookmarkable and preserves state across navigation.
function usePagedListFromURL<T>(endpoint: string) {
const [searchParams, setSearchParams] = useSearchParams();
const page = parseInt(searchParams.get('page') || '1');
const pageSize = parseInt(searchParams.get('pageSize') || '20');
const search = searchParams.get('search') || '';
const sort = searchParams.get('sort') || '';
const updateParams = (updates: Partial<UsePagedListOptions>) => {
const newParams = new URLSearchParams(searchParams);
if (updates.page) newParams.set('page', updates.page.toString());
if (updates.pageSize) newParams.set('pageSize', updates.pageSize.toString());
if (updates.search !== undefined) {
updates.search ? newParams.set('search', updates.search) : newParams.delete('search');
}
if (updates.sort !== undefined) {
updates.sort ? newParams.set('sort', updates.sort) : newParams.delete('sort');
}
setSearchParams(newParams);
};
const query = usePagedList<T>({ endpoint, page, pageSize, search, sort });
return { ...query, page, pageSize, search, sort, updateParams };
}
// Usage creates shareable URLs
function MemberList({ orgId }: { orgId: string }) {
const { data, updateParams } = usePagedListFromURL<Member>(
`/users/me/organizations/${orgId}/members`
);
// URL updates automatically when state changes
// ?page=2&search=sarah&sort=role:asc
return (
<div>
<SearchInput
value={search}
onChange={value => updateParams({ search: value, page: 1 })}
/>
{/* ... */}
</div>
);
}
This pattern works particularly well for admin dashboards where users might want to share links to specific filtered/sorted views.
Sort State as Ordered Array
For UI that supports multi-column sorting (click column header to add to sort order), represent sort state as an array:
interface SortField {
field: string;
direction: 'asc' | 'desc';
}
function useSortState(initial: SortField[] = []) {
const [sortFields, setSortFields] = useState<SortField[]>(initial);
const toggleSort = (field: string) => {
setSortFields(current => {
const existing = current.findIndex(s => s.field === field);
if (existing === -1) {
// Add new sort field
return [...current, { field, direction: 'asc' }];
}
const existingField = current[existing];
if (existingField.direction === 'asc') {
// Toggle to descending
return current.map((s, i) =>
i === existing ? { ...s, direction: 'desc' } : s
);
}
// Remove from sort
return current.filter((_, i) => i !== existing);
});
};
const sortString = sortFields
.map(s => `${s.field}:${s.direction}`)
.join(',');
return { sortFields, toggleSort, sortString };
}
// Usage
const { sortString, toggleSort } = useSortState([
{ field: 'createdAt', direction: 'desc' }
]);
const { data } = usePagedList({
endpoint: '/admin/organizations',
page: 1,
pageSize: 20,
sort: sortString // "createdAt:desc" or "createdAt:desc,name:asc"
});
This creates intuitive table sorting behavior where clicking headers builds up a sort order rather than replacing it.
Performance Considerations
Pagination and sorting happen at the database layer using EF Core's query translation. The total count is calculated efficiently using a separate COUNT query, so you get accurate pagination information without loading all items into memory.
Search uses database text search capabilities - case-insensitive LIKE queries for most fields. For large datasets, consider adding database indexes on frequently searched columns to maintain performance.
Sort operations use database indexes where available. The default sort for most resources is createdAt:desc (newest first), which typically performs well since created timestamp columns are usually indexed.
If you're building admin panels that might query large datasets, consider:
- Default to reasonable page sizes (20-50 items) rather than large pages
- Implement infinite scroll carefully - fetching page 1000 is slower than page 1 even with proper indexing
- Cache list responses at the client level to avoid redundant queries
- Use the search parameter to narrow results before paginating
Backend Implementation Notes
For developers integrating Identity Base who want to understand the implementation, the standardized behavior is backed by shared helpers in the Identity.Base package.
PageRequest is a record type that captures pagination and sorting parameters from query strings:
public record PageRequest(
int Page = 1,
int PageSize = 20,
string? Search = null,
string? Sort = null
);
PagedResult
public record PagedResult<T>(
int Page,
int PageSize,
int TotalCount,
IEnumerable<T> Items
);
SortExpression parses the sort parameter and translates it to EF Core ordering:
var sortExpression = SortExpression.Parse(request.Sort);
var query = sortExpression.Apply(baseQuery);
These helpers are used consistently across all Minimal API endpoints that return lists, ensuring uniform behavior without duplicating pagination logic in every endpoint.
Related Documentation
Guides:
- Admin Operations Guide - Building admin dashboards with pagination
Playbooks:
- Admin API Smoke Test - Testing pagination and sorting behavior
Package References:
- Identity.Base - PageRequest and PagedResult implementation details
- Identity.Base.Organizations - Organization-specific list endpoints
Changelog:
- CHANGELOG.md - Full version history
Have questions about implementing list views with pagination and sorting? Open a discussion: https://github.com/Amaretto-Software-Labs/identity-base/issues