TableX.AspNetCore 0.1.0

dotnet add package TableX.AspNetCore --version 0.1.0
                    
NuGet\Install-Package TableX.AspNetCore -Version 0.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="TableX.AspNetCore" Version="0.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="TableX.AspNetCore" Version="0.1.0" />
                    
Directory.Packages.props
<PackageReference Include="TableX.AspNetCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add TableX.AspNetCore --version 0.1.0
                    
#r "nuget: TableX.AspNetCore, 0.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package TableX.AspNetCore@0.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=TableX.AspNetCore&version=0.1.0
                    
Install as a Cake Addin
#tool nuget:?package=TableX.AspNetCore&version=0.1.0
                    
Install as a Cake Tool

TableX.AspNetCore

A professional, server-driven data grid for ASP.NET Core — the Razor half of TableX.

The grid never holds your dataset. Every user action (search, sort, page, page size, column filter) becomes a query string; your endpoint answers with exactly one page of rows and a total count. That is what makes the grid behave the same at 50 rows and at 5,000,000.

This package gives you three things and nothing you have to glue together:

TableXQuery Binds TableX's wire format straight off the query string. No configuration, no [FromQuery] gymnastics.
ToPagedResponseAsync Applies search, filters, sorting and paging to an IQueryable<T> through an explicit allowlist, and returns a PagedResponse<T>. On EF Core it leaves as two SQL statements.
Tag Helpers <table-x> and <table-x-column> render the grid, with the browser bundle shipped inside the package as a static web asset.
  • ASP.NET Core 8.0+ · zero package dependencies · MIT
  • Excel (.xls, styled badges) and CSV export, including the whole filtered dataset across pages
  • Global search (350 ms debounce), column visibility, density, row selection, automatic S.No., numbered pagination with a page-jump box
  • Responsive: a table at ≥ 768 px, a card per record below
  • Light / dark / auto theming, fully localizable, accessible by construction

<p align="center"> <img src="https://raw.githubusercontent.com/ChhaganSinha/NexGrid/master/docs/assets/tablex-preview.png" alt="TableX ASP.NET Core Data Grid Preview" width="100%" /> </p>


Install

dotnet add package TableX.AspNetCore

Reference the bundle once, in _Layout.cshtml. Both files ship inside the package as static web assets — there is nothing to copy, download, or build:

<link rel="stylesheet" href="~/_content/TableX.AspNetCore/tablex.css" />
<script src="~/_content/TableX.AspNetCore/tablex.global.js"></script>

Or without hard-coding the paths:

<link rel="stylesheet" href="@TableXAssets.StylesheetPath" />
<script src="@TableXAssets.ScriptPath"></script>

Static web assets are served by app.UseStaticFiles(), which the default templates already call. In Development they are served straight out of the package; dotnet publish copies them into your app's wwwroot. (Running a Release build without publishing is the one case where neither applies and the files 404 — publish, or set ASPNETCORE_ENVIRONMENT=Development.)

Then register the Tag Helpers once, in Views/_ViewImports.cshtml (or Pages/_ViewImports.cshtml):

@using TableX.AspNetCore
@addTagHelper *, TableX.AspNetCore

Quick start

The endpoint

[HttpGet("/api/students")]
public Task<PagedResponse<Student>> Get(TableXQuery query, CancellationToken ct) =>
    db.Students.AsNoTracking().ToPagedResponseAsync(query, options => options
        .Sortable(s => s.Name, s => s.CreatedAt)
        .Searchable(s => s.Name, s => s.Email)
        .Filterable("status", s => s.Status)
        .DefaultSort(s => s.CreatedAt, SortDirection.Descending), ct);

The view (MVC / Razor Pages)

<nex-grid caption="Students" endpoint="/api/students" enable-selection="true">
    <nex-grid-column field="name" header="Name" min-width="180" />
    <nex-grid-column field="email" header="Email" />
    <nex-grid-column field="status" header="Status" align="Center" />
    <nex-grid-column field="score" header="Score" align="Right" width="90" />
    <nex-grid-column field="createdAt" header="Enrolled" />
</nex-grid>

The view (Blazor Server, WebAssembly, or Auto)

Add @using TableX.AspNetCore.Components to _Imports.razor:

@page "/students"
@using TableX.AspNetCore.Components

<TableX TItem="Student" Caption="Students Directory" Endpoint="/api/students" EnableSelection="true">
    <TableXColumn Field="name" Header="Name" MinWidth="180" />
    <TableXColumn Field="email" Header="Email" />
    <TableXColumn Field="status" Header="Status" Align="TableXColumnAlign.Center" />
    <TableXColumn Field="score" Header="Score" Align="TableXColumnAlign.Right" Width="90" />
    <TableXColumn Field="createdAt" Header="Enrolled" />
</TableX>

That is the whole integration. The grid fetches its own data, manages its loading and error states, and re-fetches on every query change.


Wiring the query binder

TableXQuery binds itself in all three hosting styles. Nothing needs to be registered in Program.cs.

MVC and API controllers

The type carries [ModelBinder(typeof(TableXQueryModelBinder))], so an undecorated parameter is enough:

[ApiController]
[Route("api/students")]
public sealed class StudentsController(AppDbContext db) : ControllerBase
{
    [HttpGet]
    public Task<PagedResponse<Student>> Get(TableXQuery query, CancellationToken ct) => /* ... */;
}

[FromQuery] TableXQuery query works too and binds identically — the attribute only names the binding source, which this binder reads directly off HttpContext.Request.Query.

Minimal APIs

TableXQuery implements the BindAsync(HttpContext) hook that minimal APIs look for:

app.MapGet("/api/students", (TableXQuery query, AppDbContext db, CancellationToken ct) =>
    db.Students.AsNoTracking().ToPagedResponseAsync(query, o => o
        .Sortable(s => s.Name)
        .Searchable(s => s.Name, s => s.Email), ct));

Razor Pages, middleware, background jobs

Call the parser yourself:

public async Task OnGetAsync()
{
    var query = TableXQuery.Parse(Request.Query);
    Result = await db.Students.AsNoTracking().ToPagedResponseAsync(query, Configure);
}

TableXQuery.Parse(string) takes a raw query string (with or without the leading ?), which is convenient in tests.

Binding never fails

Parsing mirrors @tablex/core's parseQuery exactly, including how it degrades. A hand-edited URL produces a usable grid, not a 400:

Query string Result
?page=0, ?page=-3, ?page=abc Page = 1
?page=12abc Page = 12 (JavaScript parseInt semantics, so client and server read a URL the same way)
?pageSize=7, ?pageSize=100000 PageSize = 10 (the allowlist is 10, 25, 50, 100)
?sort=name name ascending
?sort=name:sideways name ascending
?sort=:desc dropped
?sort=a:b:desc field a:b, descending (the field is everything before the last colon)
?q= Q = null
?filter[status]= kept in Filter, but ignored when the query is applied

Full example — controller + EF Core

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using TableX.AspNetCore;

public enum StudentStatus { Active, Suspended, Alumni }

public sealed class Student
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public string Email { get; set; } = "";
    public StudentStatus Status { get; set; }
    public int Score { get; set; }
    public DateTime CreatedAt { get; set; }
    public string? InternalNotes { get; set; }   // never exposed: not allowlisted
}

[ApiController]
[Route("api/students")]
public sealed class StudentsController(AppDbContext db) : ControllerBase
{
    [HttpGet]
    public Task<PagedResponse<StudentRow>> Get(TableXQuery query, CancellationToken ct) =>
        db.Students
            .AsNoTracking()
            .Where(s => s.TenantId == User.TenantId())   // authorization first, always
            .Select(s => new StudentRow(s.Id, s.Name, s.Email, s.Status, s.Score, s.CreatedAt))
            .ToPagedResponseAsync(query, options => options
                .Sortable(s => s.Name, s => s.Score, s => s.CreatedAt)
                .Searchable(s => s.Name, s => s.Email)
                .Filterable("status", s => s.Status)
                .DefaultSort(s => s.CreatedAt, SortDirection.Descending), ct);
}

public sealed record StudentRow(
    int Id, string Name, string Email, StudentStatus Status, int Score, DateTime CreatedAt);

Two round trips reach the database, and neither materialises a row the user is not looking at:

SELECT COUNT(*) FROM [Students] WHERE [TenantId] = @tenant AND ([Name] LIKE @q OR [Email] LIKE @q);

SELECT [Id], [Name], [Email], [Status], [Score], [CreatedAt] FROM [Students]
WHERE [TenantId] = @tenant AND ([Name] LIKE @q OR [Email] LIKE @q)
ORDER BY [CreatedAt] DESC
OFFSET @skip ROWS FETCH NEXT @take ROWS ONLY;

Notice @q and @take are parameters. Values from the query string are lifted into the expression tree the way a C# closure is, not baked in as constants, so the database can reuse one query plan for every search term.

Project before you page. .Select(...) into a row type keeps columns the UI never shows — InternalNotes, PasswordHash — out of the SQL entirely. The allowlist already prevents them being sorted or filtered on; projecting keeps them from being read.

Async without an EF Core dependency

ToPagedResponseAsync runs genuinely asynchronously on EF Core, but this package takes no Microsoft.EntityFrameworkCore package reference — it must not pin an EF version onto your app, and it must stay usable over other providers. The async path is discovered at run time instead: rows are materialised through IAsyncEnumerable<T> (a BCL interface EF Core's queryables implement), and the count goes through EF Core's IAsyncQueryProvider located by type name. Anything without an async surface — LINQ to Objects, an in-memory test double — falls back to the synchronous path, which is the correct answer there anyway.


Full example — minimal API

using Microsoft.EntityFrameworkCore;
using TableX.AspNetCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(o => o.UseSqlServer(/* ... */));
builder.Services.AddRazorPages();

var app = builder.Build();
app.UseStaticFiles();       // serves _content/TableX.AspNetCore/*
app.MapRazorPages();

app.MapGet("/api/students", (TableXQuery query, AppDbContext db, CancellationToken ct) =>
    db.Students
        .AsNoTracking()
        .ToPagedResponseAsync(query, options => options
            .Sortable(s => s.Name, s => s.Score, s => s.CreatedAt)
            .Searchable(s => s.Name, s => s.Email)
            .Filterable("status", s => s.Status)
            .DefaultSort(s => s.CreatedAt, SortDirection.Descending), ct));

app.Run();

PagedResponse<T> serializes to exactly the shape every TableX adapter reads, regardless of your app's JsonSerializerOptions:

{
  "items": [ /* exactly ONE page of rows */ ],
  "page": 2,
  "pageSize": 25,
  "total": 1284,      // full FILTERED count — this drives the pager
  "totalPages": 52
}

The allowlist, and why it is not optional

A grid sends the column ids it was configured with. The query string is not a grid — it is whatever anyone types into the address bar:

?sort=PasswordHash:asc
?filter[IsDeleted]=false
?sort=Owner.Organisation.BillingEmail:desc

A grid library that resolved those names by reflection would happily order by a password hash and let an attacker read it one binary-search page at a time.

TableX never turns a client string into a member access. Sortable, Searchable and Filterable build dictionaries from expressions the server wrote, and applying a query is a lookup:

if (!options.SortableMembers.TryGetValue(spec.Field, out var selector))
{
    continue;   // not allowlisted: dropped, never reflected
}

Which means:

  • An unregistered key does nothing. No error, no leak, no 500 — the grid renders in its default order. There is no code path from a query-string value to a property, so there is nothing to escape and nothing to sanitise.
  • Keys are matched case-insensitively against the member name, so s => s.CreatedAt answers the browser's ?sort=createdAt:desc. Use the Sortable(key, selector) / Filterable(key, selector) overloads when the column id differs from the member name, or to order by a computed expression.
  • Filter values are converted, never interpreted. ?filter[score]=banana drops the filter rather than failing the request. Strings, enums, bool, numbers, Guid, DateTime, DateTimeOffset, DateOnly and TimeOnly are supported.
  • Nothing is allowed by default. A call with no configure delegate ignores every sort, search and filter and just pages.

The allowlist is not authorization. Filter by tenant, owner, or role before ToPagedResponse — the grid pages whatever query you hand it.

Always set a DefaultSort

Skip/Take over an unordered SQL query has no defined row order. Without a default sort a user can page forward and see the same record twice, and never see another. One line fixes it:

.DefaultSort(s => s.CreatedAt, SortDirection.Descending)

Tag Helpers

<nex-grid>

Renders three things: a <div> for the bundle to mount into, a <script type="application/json"> block holding the configuration, and a short init script that calls TableX.createTableX.

Configuration never goes inside executable JavaScript. It travels in an inert JSON block written with System.Text.Json's default encoder, which escapes <, > and & — so no caption, header, or endpoint can close the <script> element early, whatever it contains.

Attribute Type Default Description
caption string required Accessible name for the table; also the default export file prefix.
endpoint string — URL the grid fetches, expecting a PagedResponse.
fetch-endpoint string endpoint Endpoint used to pull the whole filtered dataset for export.
id string generated The container element's id.
density Compact | Default | Comfortable Default Row height.
theme Light | Dark | Auto Light Colour scheme.
grid-class string — Extra classes for the grid root (.tbx-root). The element's own class stays on the mount point.
enable-search bool true Show the global search field.
search-placeholder string locale Placeholder for the search field.
enable-selection bool false Show row selection checkboxes.
enable-export bool true Show the Excel/CSV export menu.
export-file-name string slug of caption Export file prefix.
show-serial-number bool true Show the automatic S.No. column.
page int 1 Initial page.
page-size int 10 Initial rows per page; coerced to 10, 25, 50, 100.
sort string — Initial sort as a field:dir token, e.g. createdAt:desc.
search string — Initial search text.
init bool true Emit the init script. See Custom cell renderers.
nonce string — CSP nonce for the init script.

The grid handle is left on the container as element.tablex, so other scripts can call refresh(), getQuery(), getSelection() or update() on it:

document.getElementById("students-grid").tablex.refresh();

<nex-grid-column>

Attribute Type Default Description
field string required The row property this column reads, and the id used in sort= and filter[]. Match the JSON name your endpoint returns (createdAt, not CreatedAt).
header string field, capitalised Header text.
sortable bool true Whether the header sorts. Set false for columns your endpoint cannot order by.
align Left | Center | Right Left Header and cell alignment.
width int — Fixed width in px.
min-width int 120 Minimum width in px. Ignored when width is set.
flex int — Proportional width unit.
hidden bool false Start hidden (still listed in the Columns menu).
hideable bool true Whether the Columns menu may toggle it.
exportable bool true Whether it appears in CSV/Excel exports.
filterable bool false Send filter[field]=value for this column. Register the member with Filterable(...) or it is ignored.
filter-field string field Filter key when it differs from field.
filter-options string — Comma-separated allowed values; renders a picker instead of free text.

Custom cell renderers

A cell renderer is a function, and functions do not survive JSON. Set init="false" to get the container and the configuration block without the init script, then start the grid yourself:

<nex-grid id="students-grid" caption="Students" endpoint="/api/students" init="false">
    <nex-grid-column field="name" header="Name" />
    <nex-grid-column field="status" header="Status" align="Center" />
</nex-grid>

<script>
    const host = document.getElementById("students-grid");
    const config = JSON.parse(document.getElementById("students-grid-config").textContent);

    config.columns.find(c => c.accessorKey === "status").cell = ({ getValue }) => {
        const badge = document.createElement("span");
        badge.className = "badge badge-" + String(getValue()).toLowerCase();
        badge.textContent = getValue();     // textContent, never innerHTML
        return badge;
    };

    host.tablex = TableX.createTableX(host, config);
</script>

Everything @tablex/vanilla accepts is available on that object: onNotify, onRowClick, onSelectionChange, locale, badgeRules, fetchOptions, and the rest. See the @tablex/vanilla README.


The wire format

Client to server:

GET /api/students?page=2&pageSize=25&sort=name:asc&q=smith&filter[status]=Active
Parameter Meaning
page 1-based page number
pageSize Rows per page: 10, 25, 50 or 100
sort Repeatable field:dir token; first is primary
q Global search text
filter[<field>] Per-column filter

Server to client: a PagedResponse<T>. Implement those two and any TableX adapter — React, Angular, vanilla — works against your endpoint with no glue.


API reference

Models

Member Description
TableXQuery.Page / .PageSize / .Sort / .Q / .Filter The parsed request. Always in range.
TableXQuery.PrimarySort The first sort, or null.
TableXQuery.GetFilter(string field) One filter value, or null. Case-insensitive.
TableXQuery.Parse(IQueryCollection) Parse from a request.
TableXQuery.Parse(string) Parse from a raw query string.
TableXQuery.BindAsync(HttpContext) Minimal API binding hook. Called by the framework.
TableXQuery.ToQueryString() Render back to a query string.
TableXQuery.Default Page 1, default size, no sort/search/filter.
PagedResponse<T>(items, page, pageSize, total) The response contract. TotalPages is derived.
SortSpec(Field, Direction) · .ToToken() · .TryParse(token, out spec) One sort intent and its field:dir token.
SortDirection.Ascending / .Descending Sort direction.
PageSizes.All / .Default / .IsAllowed(n) / .Coerce(n) The rows-per-page allowlist, shared with @tablex/core.
TableXQueryModelBinder MVC binder. Attached automatically; you never register it.

Query

Member Description
IQueryable<T>.ToPagedResponse(query, configure?) Apply search → filters → sort → count → page.
IQueryable<T>.ToPagedResponseAsync(query, configure?, ct) Async variant. Uses the provider's async surface when it has one.
TableXQueryOptions<T>.Sortable(params Expression<Func<T, object?>>[]) Allow sorting by these members, keyed by member name.
TableXQueryOptions<T>.Sortable(string key, Expression<Func<T, object?>>) Allow sorting under an explicit key, or by a computed expression.
TableXQueryOptions<T>.Searchable(params Expression<Func<T, string?>>[]) Include these string members in the global search (OR'd Contains).
TableXQueryOptions<T>.Filterable(string key, Expression<Func<T, object?>>) Allow filter[key]=value as an equality comparison.
TableXQueryOptions<T>.Filterable(Expression<Func<T, object?>>) Same, keyed by member name.
TableXQueryOptions<T>.DefaultSort(selector, direction) Ordering used when the request carries no usable sort.

Tag Helpers and assets

Member Description
TableXTagHelper <nex-grid>
TableXColumnTagHelper <nex-grid-column>
TableXDensity · TableXTheme · TableXColumnAlign Attribute enums.
TableXAssets.StylesheetPath /_content/TableX.AspNetCore/tablex.css
TableXAssets.ScriptPath /_content/TableX.AspNetCore/tablex.global.js
TableXAssets.ContentRoot · .GlobalName Static web asset root; the browser global (TableX).

Building from source

The package embeds @tablex/vanilla's browser bundle, so build the JavaScript first:

npm install
npm run build:core && npm run build:vanilla
dotnet build dotnet/TableX.sln -c Release

The .csproj copies packages/vanilla/dist/tablex.global.js and tablex.css into wwwroot/ before every build, guarded by Exists(...). A checkout where the JavaScript has not been built still compiles — it just warns and produces a package with no browser payload.

Author & Maintainer

Chhagan Sinha


License

MIT © 2026 Chhagan Sinha. See LICENSE.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net8.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.1.0 103 9/3/2026