Implement Server‑Side Pagination, Sorting, and Filtering with Angular Material MatTable
Step‑by‑step guide to build a server‑side paginated, sortable, and filterable Angular Material table using a custom CDK DataSource.
13 Dec 2025, 09:29 UTC

Desired Outcome
Create a read‑only mat-table that loads data from a backend API on demand, updating the view whenever the user changes the page, sorts a column, or types a filter term. The table should show a loading indicator while waiting for the server and display an error message if the request fails.
Prerequisites
- Angular CLI version 15 or newer.
- Packages
@angular/materialand@angular/cdkinstalled in the project. - Basic knowledge of Angular components, RxJS Observables, and TypeScript.
- A data service that can accept pagination, sorting, and filter parameters and returns an object with
data(array of rows) andtotalCount(total number of records).
Procedure
Import the required Material modules in the feature module (e.g.,
app.module.tsor a dedicated module):import { MatTableModule } from '@angular/material/table'; import { MatPaginatorModule } from '@angular/material/paginator'; import { MatSortModule } from '@angular/material/sort'; import { MatInputModule } from '@angular/material/input'; @NgModule({ imports: [ MatTableModule, MatPaginatorModule, MatSortModule, MatInputModule, // other imports ], }) export class MyFeatureModule {}Create a custom
DataSourceclass. Place it in a file likeserver-data-source.ts:import { DataSource } from '@angular/cdk/collections'; import { Observable, Subject, merge, of } from 'rxjs'; import { map, catchError, startWith, switchMap, debounceTime } from 'rxjs/operators'; export class ServerDataSource extends DataSource { private _paginatorPage$ = new Subject(); private _sortChange$ = new Subject(); private _filterChange$ = new Subject(); constructor(private dataService: YourDataService) { super(); } /** Called by the table to obtain an observable stream of data rows */ connect(): Observable { const dataRequests = [ this._paginatorPage$, this._sortChange$, this._filterChange$.pipe(debounceTime(300)) ]; return merge(...dataRequests).pipe( startWith(null), switchMap(() => { // Build request payload from current state const payload = { pageIndex: this.paginator?.pageIndex ?? 0, pageSize: this.paginator?.pageSize ?? 10, sortActive: this.sort?.active ?? '', sortDirection: this.sort?.direction ?? '', filter: this.filterValue }; return this.dataService.getData(payload).pipe( map(res => res.data), catchError(() => of([])) // empty array on error ); }) ); } disconnect(): void { this._paginatorPage$.complete(); this._sortChange$.complete(); this._filterChange$.complete(); } // Setters used by the template set paginator(p: MatPaginator) { this._paginator = p; } private _paginator: MatPaginator | null; get paginator(): MatPaginator | null { return this._paginator; } set sort(s: MatSort) { this._sort = s; } private _sort: MatSort | null; get sort(): MatSort | null { return this._sort; } set filterValue(v: string) { this._filterChange$.next(v); } private filterValue = ''; }Use the data source in a component template (
my-table.component.html):<div class="example-container"> <mat-form-field> <input matInput (keyup)="dataSource.filterValue = $event.target.value" placeholder="Filter"> </mat-form-field> <table mat-table [dataSource]="dataSource" class="example-table"> <ng-container matColumnDef="name"> <th mat-header-cell *matHeaderCellDef mat-sort-header> Name </th> <td mat-cell *matCellDef="let row">{{row.name}}</td> </ng-container> <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> <tr mat-row *matRowDef="let row; columns: displayedColumns"></tr> </table> <mat-paginator [pageSizeOptions]="[5, 10, 25]"></mat-paginator> <div *ngIf="dataSource.isLoading$ | async" class="example-loading"> Loading... </div> <div *ngIf="dataSource.hasError$ | async" class="example-error"> Failed to load data. <button mat-button (click)="reload()">Retry</button> </div> </div>In the component class (
my-table.component.ts) instantiate the data source and wire paginator/sort:import { Component, OnInit, ViewChild } from '@angular/core'; import { MatPaginator } from '@angular/material/paginator'; import { MatSort } from '@angular/material/sort'; import { ServerDataSource } from './server-data-source'; import { YourDataService } from './your-data.service'; @Component({ selector: 'app-my-table', templateUrl: './my-table.component.html', styleUrls: ['./my-table.component.css'] }) export class MyTableComponent implements OnInit { displayedColumns: string[] = ['name', /* other columns */]; dataSource: ServerDataSource; @ViewChild(MatPaginator) paginator!: MatPaginator; @ViewChild(MatSort) sort!: MatSort; constructor(private dataService: YourDataService) { this.dataSource = new ServerDataSource(this.dataService); } ngOnInit(): void { this.dataSource.paginator = this.paginator; this.dataSource.sort = this.sort; } reload(): void { // forces a refresh by paging to first page this.paginator.firstPage(); } }Add minimal styling (
my-table.component.css) to show loading/error states:.example-container { display: flex; flex-direction: column; } .example-table { width: 100%; } .example-loading { align-self: center; padding: 16px; } .example-error { align-self: center; padding: 16px; color: rgba(0,0,0,0.87); }
Expected Checks
- On first load the table displays the first page of data returned by the service.
- The paginator shows the correct
length(total count) and updatespageIndexwhen navigating. - Clicking a column header toggles the sort arrow and triggers a new request with the updated
sortActiveandsortDirectionvalues. - Typing in the filter input (after the debounce) sends a request containing the entered filter string.
- In the browser’s DevTools Network tab, each user interaction results in exactly one XHR request with the expected query parameters.
- While a request is pending, the “Loading…” message appears; on error, the error message and retry button are shown.
Recovery Options
- If the service returns an error, the
catchError inside the data source returns an empty array, preventing the table from breaking. - The retry button calls
paginator.firstPage(), which forces a fresh request from the first page. - Optionally, cache the last successful data set in the component and display it as “stale” while a retry is in progress.
Limitations and Practical Verification
- The implementation assumes the backend returns a consistent shape:
{ data: [], totalCount: number }. Adjust the mapping inServerDataSourceif your API differs. - Memory leaks can occur if the internal subjects are not completed in
disconnect(). Always calldisconnect()(handled automatically by the table) or manually clean up inngOnDestroyif you keep a reference. - To verify correct behavior, open Chrome DevTools → Network, filter for XHR, and confirm that each pagination change, sort click, or filter keystroke (after debounce) produces a single request with the right parameters. Check that the response data populates the table rows and that the paginator’s
lengthmatches thetotalCountfield.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.