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 datasets, demos, 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 paging, sorting, 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:
- A MudDataGrid running in server mode
- A PaginationFilter contract shared between UI and API
- Server-side paging, multi-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 Paging, Sorting 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 pagestate.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-based, APIs 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 paging, sorting, 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 pageTotalItems– 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 boundaries, paging, sorting, and filtering stop being fragile UI tricks and become part of a clean system design.
If it’s a contract, generate 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!