← Back to the manual

Angular + ASP.NET Core: a server-side grid in ~30 lines

inandu-grid's serverSide mode and the Inandu.Grid.Extensions NuGet package are designed as a matched pair: the grid emits plain events on every sort, page or filter change; the API turns the resulting request into one paged EF Core query. No client-side data-fetching library, no server-side query builder to write by hand.

Try the live demo Server-side mode in the manual Inandu.Grid.Extensions on NuGet

1. The API (ASP.NET Core + EF Core)

dotnet add package Inandu.Grid.Extensions.EntityFrameworkCore
using Inandu.Grid.Extensions.EntityFrameworkCore;

app.MapGet("/api/products", (HttpRequest request, AppDb db, CancellationToken ct) =>
    db.Products.AsNoTracking().ToInanduGridAsync(request.QueryString.Value, options =>
    {
        options.DefaultPageSize  = 25;
        options.MaxPageSize      = 200;
        options.SearchableFields = new() { nameof(Product.Name), nameof(Product.Sku) };
    }, ct: ct));

ToInanduGridAsync reads page/pageSize, sort, q and any {field}_{operator} column filters straight off the query string, runs CountAsync + ToListAsync, and returns { data, total, page, pageSize, pageCount } — exactly the shape the grid needs. Only one page of rows is ever read from the database; sorting and filtering translate to SQL, not an in-memory scan.

2. The grid (Angular)

<inandu-grid
  [data]="pageRows()"
  serverSide="true"
  [totalItems]="total()"
  [loading]="loading()"
  [error]="error()"
  [paging]="{ pageSize: 25 }"
  (sortChange)="onSort($event)"
  (pageChange)="onPage($event)"
  (filterChange)="onFilter($event)"
  filter="true">
  <inandu-column field="name" title="Name" sortable="true" filter="yes" />
  <inandu-column field="sku" title="SKU" filter="yes" />
  <inandu-column field="price" title="Price" type="number" sortable="true" filter="yes" />
</inandu-grid>

Once serverSide is on, the grid trusts [data] to already be the current page and stops sorting/filtering/paging locally. It fires (sortChange) / (pageChange) / (filterChange) on every relevant change — including once on init — so a single handler chain drives the first fetch too.

3. The glue: translating grid events into the request

The free grid's events are plain objects — InanduGridSortCriterion[], { page, pageSize }, { query, columnFilters } — not HTTP params. This service keeps the current request state in signals and rebuilds the query string Inandu.Grid.Extensions expects (page, pageSize, sort=-field, q=, field_gte= / field_lte= / field_contains=) on every change:

@Injectable({ providedIn: 'root' })
export class ProductGridService {
  private readonly http = inject(HttpClient);

  readonly pageRows = signal<Product[]>([]);
  readonly total = signal(0);
  readonly loading = signal(false);
  readonly error = signal('');

  private page = 1;
  private pageSize = 25;
  private sort: InanduGridSortCriterion[] = [];
  private query = '';
  private columnFilters: Record<string, InanduGridColumnFilterValue> = {};

  onSort(criteria: InanduGridSortCriterion[]) { this.sort = criteria; this.page = 1; this.fetch(); }
  onPage(p: { page: number; pageSize: number }) { this.page = p.page; this.pageSize = p.pageSize; this.fetch(); }
  onFilter(f: { query: string; columnFilters: Record<string, InanduGridColumnFilterValue> }) {
    this.query = f.query; this.columnFilters = f.columnFilters; this.page = 1; this.fetch();
  }

  private fetch() {
    this.loading.set(true);
    this.error.set('');
    const params = new URLSearchParams({ page: String(this.page), pageSize: String(this.pageSize) });
    if (this.query) params.set('q', this.query);
    this.sort.forEach(s => params.append('sort', s.direction === 'desc' ? `-${s.field}` : s.field));
    for (const [field, value] of Object.entries(this.columnFilters)) {
      if (value.text) params.set(`${field}_contains`, value.text);
      if (value.min != null) params.set(`${field}_gte`, String(value.min));
      if (value.max != null) params.set(`${field}_lte`, String(value.max));
      if (value.from) params.set(`${field}_gte`, value.from);
      if (value.to) params.set(`${field}_lte`, value.to);
      if (value.bool) params.set(`${field}_eq`, value.bool);
    }
    this.http.get<InanduGridResult<Product>>(`/api/products?${params}`).subscribe({
      next: res => { this.pageRows.set(res.data); this.total.set(res.total); this.loading.set(false); },
      error: () => { this.error.set('Could not load products.'); this.loading.set(false); },
    });
  }
}
Why this glue exists This translation is exactly what @inandu-solutions/grid-pro's createInanduGridDataSource/createInanduGridServerRowModel do for you — if you'd rather not maintain this service by hand across every grid in your app, that's what they're for. The free grid ships the events; the wiring above is the whole cost of staying on the MIT tier.

4. Notes

Full server-side reference: manual §11. Full request contract: request-contract.md. Questions: open an issue.