End-to-End Server-Side Paging, Sorting, and Filtering in Blazor

A practical MudBlazor + API pattern that actually scales

Read this article on Medium.

Let’s Build Something Real

Client-side paging works… until it doesn’t. It’s fine for small datasetsdemos, or admin screens that will never grow. But once your data starts to scale, the cracks show fast: slow load times, inconsistent sorting, broken filters, and incorrect total counts.

In this article, we’ll walk through a real, production-style implementation of server-side pagingsorting, and filtering using Blazor + MudBlazor, a clean API boundary, and a predictable pagination contract.

Case Study

In our case, an overnight influx of 200,000 users caused our API calls to start timing out, and suddenly our small admin grids were broken. That’s when we realized: for real systems, the server must own paging, sorting, filtering, and total counts.

What We’re Building

By the end, we’ll have:

  • MudDataGrid running in server mode
  • PaginationFilter contract shared between UI and API
  • Server-side pagingmulti-column sorting, and advanced filtering
  • A clean Mediator + Specification + Repository pipeline

Each layer has a single responsibility, and none of them know more than they need to.

How the Data Flows

MudDataGrid
↓ (GridState)
PaginationFilter

API Endpoint

Specification

Repository / Database

The grid never touches data directly.
It only 
describes intent.

Why Server-Side Data Grids Matter

Client-side grids work by loading everything, then paging and sorting in memory. That approach breaks down quickly when:

  • Datasets grow beyond a few hundred records
  • Filters become dynamic or combinable
  • Sorting spans multiple columns
  • Accurate total counts are required

Server-side grids flip the model:

  • The UI becomes stateless
  • Every interaction is explicit
  • The API stays reusable and predictable

The grid stops being clever and starts being honest.

Step 1: Enable Server Mode in MudDataGrid

The key switch is ServerData. Once enabled, every grid interaction becomes a round-trip.

<MudDataGrid T="UserViewModel"
ServerData="LoadServerDataAsync"
RowsPerPage="10"
SortMode="SortMode.Multiple">

At this point PagingSorting and Filtering are no longer handled by the grid. Instead, it delegates all of that responsibility to your callback. Every user action becomes a round-trip to the server. That’s exactly what we want.

Step 2: Understanding GridState<T>

MudBlazor calls your server callback with a GridState<T>:

private async Task<GridData<UserViewModel>> LoadServerDataAsync(
GridState<UserViewModel> state
)

This object doesn’t contain data. It describes user intent.

The most important fields are:

  • state.Page – the current page index (0-based)
  • state.PageSize – how many rows per page
  • state.SortDefinitions – active sort columns and directions

The grid does not remember anything for you. Each call must be treated as a fresh request.

Step 3: Translate UI State into a Pagination Contract

Rather than leaking UI concepts into the API, we translate GridState into a PaginationFilter.

PaginationFilter filter = new()
{
Keyword = SearchText,
PageNumber = state.Page + 1,
PageSize = state.PageSize,
OrderBy = BuildOrderByList(state.SortDefinitions),
AdvancedFilter = BuildAdvancedFilter()
};

This translation is intentional:

  • UI paging is 0-basedAPIs are usually 1-based
  • Sorting must be explicit and repeatable
  • Filters must be composable and optional

The result is a single object that fully describes the request.

This filter becomes your single source of truth for pagingsorting, and filtering.

Step 4: Translating Sort Definitions Safely

UI sort state does not map cleanly to database columns. That translation must be explicit and safe.

private static List<string> BuildOrderByList(IEnumerable<SortDefinition<UserViewModel>>? sortDefinitions)
{
List<string> orderByList = [];

if (sortDefinitions?.Any() == true)
{
foreach (SortDefinition<UserViewModel> sortDef in sortDefinitions)
{
string fieldName;

if (!string.IsNullOrWhiteSpace(sortDef.SortBy))
{
fieldName = sortDef.SortBy;
}
else if (sortDef.SortFunc != null)
{
fieldName = sortDef.SortFunc.Method.Name;
}
else
{
fieldName = "UserName";
}

string direction = sortDef.Descending ? "Desc" : "Asc";

string orderByValue = $"{fieldName} {direction}";
orderByList.Add(orderByValue);
}
}
else
{
orderByList.Add("UserName Asc");
}

return orderByList;
}

What this method does:

  • Preserves multi-column sort order
  • Converts UI intent into API-friendly strings
  • Applies a default sort when none is provided

Sorting is part of your API contract, not a UI detail.

Step 5: Advanced Filtering Without Query Spaghetti

Instead of hardcoding filters, we build them dynamically.

private Filter? BuildAdvancedFilter()
{
Filter? result = null;

List<Filter> filters = [];

if (IsApproved > -1)
{
filters.Add(new Filter
{
Field = "IsApproved",
Operator = "eq",
Value = IsApproved == 1
});
}

if (IsEnabled > -1)
{
filters.Add(new Filter
{
Field = "IsLockedOut",
Operator = "eq",
Value = IsEnabled == 0
});
}

if (filters.Count > 0)
{
result = new Filter
{
Logic = "and",
Filters = filters
};
}

return result;
}

This approach allows:

  • Optional filters
  • Logical grouping (and / or)
  • Future expansion without breaking the UI

The UI decides intent.
The 
server decides execution.

Step 6: Returning Data to the Grid

Everything comes together in the server callback:

private async Task<GridData<UserViewModel>> LoadServerDataAsync(GridState<UserViewModel> state)
{
GridData<UserViewModel> gridResult = new() { TotalItems = 0, Items = [] };

Filter? advancedFilter = BuildAdvancedFilter();
PaginationFilter filter = new()
{
AdvancedFilter = advancedFilter,
Keyword = SearchText,
PageNumber = state.Page + 1,
PageSize = state.PageSize,
OrderBy = BuildOrderByList(state.SortDefinitions)
};

PaginatedResults<UserViewModel>? result = await LoadUsersAsync(filter);

if (result is not null)
{
gridResult = new GridData<UserViewModel>
{
Items = result.Items,
TotalItems = result.TotalCount
};
}

return gridResult;
}

Two things matter here:

  • Items – the current page
  • TotalItems – the total count across all pages

Without TotalItems, paging cannot work correctly.

Why This Approach Scales

  • Large datasets stay fast
  • APIs remain reusable
  • UI stays stateless
  • Sorting and filtering remain explicit

Most importantly, nothing is hidden.

Common Pitfalls (and How to Avoid Them)

  • Forgetting TotalItems
    Without it, the grid cannot calculate page counts and will behave erratically.
  • No default sort
    Databases do not guarantee order. Always apply a deterministic fallback.
  • 0-based vs 1-based paging
    UI components often start at zero. APIs almost never do.
  • Leaking UI field names into the database
    Always translate sort definitions explicitly.
  • Client-side filtering mixed with server-side paging
    This creates inconsistent totals and broken navigation.

When You Don’t Need Server-Side Paging

  • Static lookup tables
  • Small admin lists (<200 rows)
  • Screens where total count doesn’t matter

Don’t over-engineer. Use server-side grids when scale or correctness demands it.

Final Thoughts

Server-side grids aren’t harder — they’re just more honest.

Once you embrace explicit contracts and clear boundariespagingsorting, and filtering stop being fragile UI tricks and become part of a clean system design.

If it’s a contractgenerate it.
If it’s 
internal, keep it flexible.

Bonus: Full Grid Example

@page "/users"

@attribute [Authorize]

<PageContent Title="@Culture["UsersLabel"]" ParentIsLoading="IsLoading" RequireRole="@string.Join(' ', AuthorizationTypes.ViewUsersRoles)" IsLandingPage>
<RightOfTitle>
<MudStack Row Class="mud-stack-align-center">
<ActionButton Disabled="IsLoading"
Variant="Standards.ButtonVariant"
StartIcon="@Icons.Material.Filled.RotateRight"
Color="Color.Secondary"
OnClick="RefreshUsersAsync">
@Culture["BtnRefreshLabel"]
</ActionButton>
</MudStack>
</RightOfTitle>
<ChildContent>
<MudCard Class="grid-container">
<MudCardContent>
<MudDataGrid T="UserViewModel"
@ref="DataGridRef"
ServerData="LoadServerDataAsync"
RowsPerPage="10"
Class="custom-grid"
SortMode="SortMode.Multiple"
ColumnResizeMode="ResizeMode.Column"
Dense="true"
Elevation="0"
Bordered="true"
Striped="true"
Hover="true"
Breakpoint="Breakpoint.Sm">

<!-- Toolbar -->
<ToolBarContent>

<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">

<MudSelect T="int"
Variant="Variant.Outlined"
ShrinkLabel="true"
Label="@Culture["EnabledLabel"]"
Value="IsEnabled"
ValueChanged="ToggleIsDisabled"
Dense="true"
Style="width:250px"
Class="mr-2"
Clearable="false">
@foreach (var option in ApprovalOptions)
{
<MudSelectItem Value="option.Value">
@option.Text
</MudSelectItem>
}
</MudSelect>

<MudSelect T="int"
Variant="Variant.Outlined"
Class="mr-4"
ShrinkLabel="true"
Label="@Culture["ApprovedLabel"]"
Value="IsApproved"
ValueChanged="ToggleIsApproved"
Dense="true"
Style="width:250px"
Clearable="false">

@foreach (var option in ApprovalOptions)
{
<MudSelectItem Value="option.Value">
@option.Text
</MudSelectItem>
}
</MudSelect>

</MudStack>

<MudButton OnClick="() => ViewDetail(string.Empty)"
Variant="Variant.Filled"
StartIcon="@Icons.Material.Filled.AddCircle"
Color="Color.Success">
@Culture["AddNewLabel"]
</MudButton>

<MudSpacer />

<MudTextField T="string"
@bind-Value="SearchText"
DebounceInterval="400"
Clearable="true"
ShrinkLabel="true"
Label="@Culture["SearchLabel"]"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
IconSize="Size.Medium"
OnDebounceIntervalElapsed="OnSearchTextChangedAsync"
Class="search" />

</ToolBarContent>

<!-- Columns -->
<Columns>
<TemplateColumn Title="@Culture["ActionLabel"]" Sortable="false">
<CellTemplate>
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.Center">
@{
if (context.Item.Data.IsLockedOut.GetValueOrDefault())
{
<MudTooltip Text="@(Culture["EnableLabel"])">
<MudFab class="mud-fab-xs mr-2" onclick="() => EnableUser(context.Item.Data.Id, context.Item.Data.UserName ?? string.Empty)" Color="Color.Primary" Size="Size.Small" StartIcon="@Icons.Material.Filled.LockOpen" />
</MudTooltip>
}
else
{
<MudTooltip Text="@(Culture["DisableLabel"])">
<MudFab class="mud-fab-xs mr-2" onclick="() => ConfirmDisableUser(context.Item.Data.Id, context.Item.Data.UserName ?? string.Empty)" Color="Color.Error" Size="Size.Small" StartIcon="@Icons.Material.Filled.Lock" />
</MudTooltip>
}
}
<MudTooltip Text="@(Culture["EditLabel"])">
<MudFab class="mud-fab-xs mr-2" onclick="() => ViewDetail(context.Item.Data.Id)" Color="Color.Info" Size="Size.Small" StartIcon="@Icons.Material.Filled.Edit" />
</MudTooltip>
</MudStack>
</CellTemplate>
</TemplateColumn>
<PropertyColumn Sortable="true" SortBy="@(u => u.Data.UserName)" Property="x => x.Data.UserName" Title="@Culture["UserNameLabel"]" />
<PropertyColumn Sortable="true" SortBy="@(u => u.Data.Email)" Property="x => x.Data.Email" Title="@Culture["EmailLabel"]" />
<TemplateColumn Sortable="true" SortBy="@(u => u.Data.IsApproved)" CellClass="text-nowrap" Title="@Culture["EnabledLabel"]">
<CellTemplate Context="user">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Size="Size.Small" Icon="@Icons.Material.Filled.Circle" Color="@(user.Item.Data.IsLockedOut.GetValueOrDefault() ? Color.Error : Color.Success)" />
<MudText>@(user.Item.Data.IsLockedOut.GetValueOrDefault() ? Culture["NoLabel"] : Culture["YesLabel"])</MudText>
</MudStack>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Sortable="true" SortBy="@(u => u.Data.IsApproved)" CellClass="text-nowrap" Title="@Culture["ApprovedLabel"]">
<CellTemplate Context="user">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Size="Size.Small" Icon="@Icons.Material.Filled.Circle" Color="@(user.Item.Data.IsApproved.GetValueOrDefault() ? Color.Success : Color.Error)" />
<MudText>@(user.Item.Data.IsApproved.GetValueOrDefault() ? Culture["YesLabel"] : Culture["NoLabel"])</MudText>
</MudStack>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Sortable="true" SortBy="@(u => u.Data.LastActivityDate)" CellClass="text-nowrap" Title="@Culture["LastActivityLabel"]">
<CellTemplate Context="user">
@DateHelper.DisplayDate(user.Item.Data.LastActivityDate)
</CellTemplate>
</TemplateColumn>
<TemplateColumn Sortable="false" Title="@Culture["RolesLabel"]">
<CellTemplate Context="user">
@GetRolesDisplay(user.Item)
</CellTemplate>
</TemplateColumn>
</Columns>

<!-- No Records -->
<NoRecordsContent>
<MudText Class="no-records">@Culture["NoItemsFoundLabel"]</MudText>
</NoRecordsContent>

<!-- Pagination -->
<PagerContent>
<MudStack class="paginator" Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudText Class="ml-4">@($"{Culture["TotalItemsTitle"]} {TotalItems}")</MudText>
<MudDataGridPager T="UserViewModel" />
</MudStack>
</PagerContent>

</MudDataGrid>
</MudCardContent>
</MudCard>
</ChildContent>
</PageContent>
<style>
table tr td.mud-table-cell, table tr td p {
font-size: 14px !important;
}

.no-wrap-cell, .text-nowrap {
white-space: nowrap;
}

</style>

Happy coding!