Datatable
Rich data table with search, sort, paginate, row actions, expandable rows — `items` or `api-url` binding.
Install
ndui add datatable
Source files land in Components/Datatable/, namespace-rewritten to your project's root namespace. The component CSS is wired into wwwroot/css/app.css automatically.
Dependencies
- Noundry.UI
See it in action
Every registered variant, state, and interaction pattern is demonstrated at the live showcase:
Deep dive — a wired products page in ~25 lines of Razor
DataTable is Studio's flagship recipe — a facade over Noundry.UI's TagHelper plus the opinionated server-side pattern (Post-Redirect-Get, TempData toasts, modal create/edit) the starter already demonstrates. The rest of this page is that complete working page: the Product entity, a ProductService over Tuxedo, the PageModel handlers, and the single .cshtml that ties them together.
#The data layer
A small Tuxedo-backed service over IDbConnection — sort-column whitelist, cancellation-aware. (Use whatever data layer you prefer; Studio doesn't require Tuxedo.)
// Features/Products/Services/ProductService.cs
public sealed record Product(int Id, string Name, decimal Price, int Stock,
bool InStock, bool Featured, ProductCategory Category,
DateOnly? ReleaseDate, DateTimeOffset CreatedAt);
public sealed class ProductService(IDbConnection db)
{
public async Task<(IReadOnlyList<Product> Items, int Total)> ListAsync(
string? search, string sort, string dir, int page, int pageSize,
CancellationToken ct = default)
{
// …parameterized SELECT + COUNT, sort-column whitelist enforced…
}
public async Task<bool> DeleteAsync(int id, CancellationToken ct = default) =>
await db.ExecuteAsync("DELETE FROM products WHERE id = @id", new { id }, ct) == 1;
}
See the full shipping source in the starter at Features/Products/Services/ProductService.cs — it's 200 lines and handles search, whitelist sort, pagination, SKU-exists checks, and stats roll-ups.
#The PageModel
// Features/Products/Pages/Index.cshtml.cs
public sealed class IndexModel(ProductService products) : PageModel
{
public IReadOnlyList<Product> Items { get; private set; } = [];
[BindProperty]
public ProductEditForm NewProduct { get; set; } = new();
public async Task<IActionResult> OnGetAsync(CancellationToken ct)
{
(Items, _) = await products.ListAsync(null, "name", "asc", 1, 1000, ct);
return Page();
}
public async Task<IActionResult> OnPostCreateAsync(CancellationToken ct)
{
if (!ModelState.IsValid) { /* re-render; keep modal open */ return Page(); }
await products.CreateAsync(NewProduct, ct);
this.AddToast($"Created {NewProduct.Name}.", ToastLevel.Success);
return RedirectToPage(); // Post-Redirect-Get; toast survives via TempData
}
public async Task<IActionResult> OnPostDeleteAsync(int id, CancellationToken ct)
{
await products.DeleteAsync(id, ct);
this.AddToast("Product deleted.", ToastLevel.Success);
return RedirectToPage();
}
}
#The view — one does it all
Noundry.UI's data-table handles search, sort, and pagination client-side once you've shipped it a collection. The server's job is just to hand it Model.Items; the TagHelper takes care of the rest.
@page "/products"
@model __ROOT_NAMESPACE__.Pages.Products.IndexModel
<header class="flex items-end justify-between mb-6">
<h1 class="text-3xl font-semibold">Products</h1>
<button @@click="$dispatch('open-modal', 'product_new')"
class="rounded-xl bg-indigo-600 text-white px-4 py-2.5 font-semibold">
New product
</button>
</header>
<noundry-data-table items="Model.Items"
per-page="10"
show-search="true"
search-placeholder="Search name or SKU…"
show-pagination="true"
striped="true" hoverable="true">
<noundry-data-table-column key="Sku" label="SKU" sortable="true" href="/products/{Id}" />
<noundry-data-table-column key="Name" label="Name" sortable="true" href="/products/{Id}" />
<noundry-data-table-column key="Category" label="Category" sortable="true" />
<noundry-data-table-column key="Price" label="Price" type="currency" sortable="true" align="right" />
<noundry-data-table-column key="Stock" label="Stock" sortable="true" align="right" />
<noundry-data-table-column key="InStock" label="Status" type="badge" badge-colors="True=green,False=red" />
</noundry-data-table>
<noundry-modal id="product_new" title="New product" size="lg">
<form method="post" asp-page-handler="Create">
<partial name="_ProductForm" model="Model.NewProduct" />
</form>
</noundry-modal>
That's it. 25 lines for a catalog with search, sort, paginate, row-links, inline status badges, and a create modal. The TagHelper writes the Alpine state, the Tailwind classes, and the pagination buttons for you.
#How the flow works
Noundry.UI's data-table is client-side everything — it filters, sorts, and paginates the array you handed it without round-tripping. You ship all rows on first load (or use api-url= for an API-driven grid when your dataset doesn't fit in a single request).
For mutations (Create / Delete), the pattern is Post-Redirect-Get:
- User submits the create form inside the modal →
POST /products?handler=Create - Server validates, saves, calls
this.AddToast("Created.", ToastLevel.Success) - Server returns
RedirectToPage()→ 303 See Other →GET /products - Browser reloads the page;
surfaces the buffered toast via its globalwindow.toast.success()API - The modal is gone because it's a fresh page render
No fragment swap, no server-dispatched close event — just a clean HTTP redirect. The modal's open-modal event is window-scoped so any button dispatching $dispatch('open-modal', 'product_new') opens it.
#Column types
Noundry's data-table-column has a type attribute that changes how values render:
type | Renders |
|---|---|
text (default) | Plain text |
badge | Colored pill; use badge-colors="Active=green,Inactive=red" |
boolean | ✓ / ✗ icons |
currency | Locale-formatted; use currency="USD" for a different ISO code |
date | Short date |
actions | Row actions via actions="edit,delete" + action-event="product" |
#Variants
#Server-side data (API-driven grid)
For datasets too large to ship up front, use the modern instead — it fetches from an endpoint:
<noundry-data-grid api-url="/api/products"
page-size="25"
default-sort-column="name"
enable-search="true"
enable-refresh="true">
<noundry-data-grid-col key="name" label="Name" sortable="true" />
<noundry-data-grid-col key="price" label="Price" type="currency" sortable="true" />
</noundry-data-grid>
The grid expects JSON { "data": [...], "total": N } from the endpoint (or a plain array with X-Total-Count header).
#Expandable rows
child expands a row to show detail pulled from a per-row API call. See the Noundry.UI component docs for the full attribute reference.
#Related
- Chart — the visual cousin for dashboards.
- Dialog — the modal create/edit flow used above (Noundry.UI calls it
; Studio kept thedialogname for catalog symmetry). - Inline validation pattern — how
ModelState.IsValidkeeps the modal open with errors. - Noundry.UI
reference — authoritative attribute list.
Related
Browse all data display · CLI reference · reactivity patterns.