Server-side helpers and model‑binding utilities for jQuery DataTables in ASP.NET Core. Provides strongly typed request binding, DataTables‑compliant response generation, optional additional parameters, draw validation, and conventions for camelCase or legacy notation. Designed for Razor Pages and easy EF Core integration.
$ dotnet add package DataTablesDotAspNetCoreServer-side processing helpers for jQuery DataTables in ASP.NET Core.
This library focuses on:
Install-Package DataTables.AspNetCore
dotnet add package DataTables.AspNetCore
<PackageReference Include="DataTables.AspNetCore" Version="*" />
Program.cs
var builder = WebApplication.CreateBuilder(args);
// Register DataTables services
builder.Services.AddDataTables();
// Register Razor Pages (recommended for this workspace)
builder.Services.AddRazorPages();
var app = builder.Build();
app.UseStaticFiles();
app.MapRazorPages();
app.Run();
Pages/Customers.cshtml.cs
using DataTables.AspNetCore.Base;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
public class CustomersModel : PageModel
{
private static readonly List<CustomerDto> _data = new()
{
new CustomerDto(1, "Ada Lovelace", "ada@example.com"),
new CustomerDto(2, "Alan Turing", "alan@example.com"),
new CustomerDto(3, "Grace Hopper", "grace@example.com"),
};
public void OnGet() { }
public IActionResult OnPostData([FromBody] IDataTableRequest request)
{
var query = _data.AsQueryable();
// Global search
if (!string.IsNullOrWhiteSpace(request.Search?.Value))
{
var searchValue = request.Search.Value;
query = query.Where(x =>
x.Name.Contains(searchValue, StringComparison.OrdinalIgnoreCase) ||
x.Email.Contains(searchValue, StringComparison.OrdinalIgnoreCase));
}
var totalRecords = _data.Count;
var filteredRecords = query.Count();
// Paging
var data = query
.Skip(request.Start)
.Take(request.Length)
.ToList();
return new JsonResult(request.CreateResponse(totalRecords, filteredRecords, data));
}
}
public sealed record CustomerDto(int Id, string Name, string Email);
Pages/Customers.cshtml
@page
@model CustomersModel
<!DOCTYPE html>
<html>
<head>
<title>Customers</title>
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/jquery.dataTables.min.css" />
</head>
<body>
<table id="customers" class="display">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
</tr>
</thead>
</table>
<script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script>
<script>
$(function () {
$('#customers').DataTable({
serverSide: true,
processing: true,
ajax: {
url: '@Url.Page("Customers", "Data")',
type: 'POST',
contentType: 'application/json',
data: function (d) {
return JSON.stringify(d);
}
},
columns: [
{ data: 'id' },
{ data: 'name' },
{ data: 'email' }
]
});
});
</script>
</body>
</html>
Program.cs
builder.Services.AddDataTables();
Configure DataTables behavior with custom options:
Program.cs
builder.Services.AddDataTables(new DataTableOptions()
.SetDefaultPageLength(25) // Default records per page
.EnableDrawValidation() // Prevent CSRF attacks (recommended)
.UseCamelCase()); // Use camelCase for DataTables 1.10+
Send and receive custom parameters alongside the standard DataTables payload:
Program.cs
using Microsoft.AspNetCore.Mvc.ModelBinding;
builder.Services.AddDataTables(
parseRequestAdditionalParameters: (ModelBindingContext context) =>
{
var additional = new Dictionary<string, object>();
// Example: department filter
var departmentId = context.ValueProvider.GetValue("departmentId").FirstValue;
if (!string.IsNullOrEmpty(departmentId) && int.TryParse(departmentId, out var deptId))
{
additional["departmentId"] = deptId;
}
// Example: date range filter
var startDate = context.ValueProvider.GetValue("startDate").FirstValue;
if (!string.IsNullOrEmpty(startDate) && DateTime.TryParse(startDate, out var start))
{
additional["startDate"] = start;
}
return additional;
},
parseResponseAdditionalParameters: true
);
Client-side usage:
$('#myTable').DataTable({
serverSide: true,
ajax: {
url: '@Url.Page("MyPage", "Data")',
type: 'POST',
contentType: 'application/json',
data: function (d) {
d.departmentId = $('#departmentFilter').val();
d.startDate = $('#startDateFilter').val();
return JSON.stringify(d);
}
}
});
Server-side access:
public IActionResult OnPostData([FromBody] IDataTableRequest request)
{
if (request.AdditionalParameters.TryGetValue("departmentId", out var deptIdObj))
{
var departmentId = Convert.ToInt32(deptIdObj); // Apply department filter
}
// ... rest of handler
}
See Quick Start above for a complete Razor Pages example.
Enable individual column search filters:
Pages/Products.cshtml.cs
public IActionResult OnPostData([FromBody] IDataTableRequest request)
{
var query = _context.Products.AsQueryable();
// Global search
if (!string.IsNullOrWhiteSpace(request.Search?.Value))
{
var searchValue = request.Search.Value;
query = query.Where(p =>
p.Name.Contains(searchValue) ||
p.Description.Contains(searchValue));
}
// Column-specific search
foreach (var column in request.Columns.Where(c => c.Searchable && !string.IsNullOrWhiteSpace(c.Search?.Value)))
{
var searchValue = column.Search.Value;
switch (column.Data)
{
case "name":
query = query.Where(p => p.Name.Contains(searchValue));
break;
case "category":
query = query.Where(p => p.Category.Contains(searchValue));
break;
case "price":
if (decimal.TryParse(searchValue, out var price))
{
query = query.Where(p => p.Price == price);
}
break;
}
}
var totalRecords = _context.Products.Count();
var filteredRecords = query.Count();
var data = query.Skip(request.Start).Take(request.Length).ToList();
return new JsonResult(request.CreateResponse(totalRecords, filteredRecords, data));
}
Handle custom sorting logic:
Pages/Orders.cshtml.cs
public IActionResult OnPostData([FromBody] IDataTableRequest request)
{
var query = _context.Orders.Include(o => o.Customer).AsQueryable();
// Apply sorting
if (request.Sorting.Any())
{
var firstSort = request.Sorting.First();
var column = request.Columns[firstSort.ColumnIndex];
query = column.Data switch
{
"orderDate" => firstSort.IsAscending
? query.OrderBy(o => o.OrderDate)
: query.OrderByDescending(o => o.OrderDate),
"customerName" => firstSort.IsAscending
? query.OrderBy(o => o.Customer.Name)
: query.OrderByDescending(o => o.Customer.Name),
"total" => firstSort.IsAscending
? query.OrderBy(o => o.Total)
: query.OrderByDescending(o => o.Total),
_ => query.OrderByDescending(o => o.OrderDate) // Default sort
};
// Apply additional sorts
foreach (var sort in request.Sorting.Skip(1))
{
var col = request.Columns[sort.ColumnIndex];
// Apply ThenBy/ThenByDescending logic
}
}
var totalRecords = _context.Orders.Count();
var filteredRecords = query.Count();
var data = query.Skip(request.Start).Take(request.Length).ToList();
return new JsonResult(request.CreateResponse(totalRecords, filteredRecords, data));
}
Full example with EF Core, async operations, and projections:
Pages/Employees.cshtml.cs
using DataTables.AspNetCore.Base;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
public class EmployeesModel : PageModel
{
private readonly ApplicationDbContext _context;
public EmployeesModel(ApplicationDbContext context)
{
_context = context;
}
public void OnGet() { }
public async Task<IActionResult> OnPostDataAsync([FromBody] IDataTableRequest request)
{
// Start with base query
var query = _context.Employees
.Include(e => e.Department)
.AsQueryable();
// Total records before filtering
var totalRecords = await _context.Employees.CountAsync();
// Apply global search
if (!string.IsNullOrWhiteSpace(request.Search?.Value))
{
var searchValue = request.Search.Value.ToLower();
query = query.Where(e =>
e.FirstName.ToLower().Contains(searchValue) ||
e.LastName.ToLower().Contains(searchValue) ||
e.Email.ToLower().Contains(searchValue) ||
e.Department.Name.ToLower().Contains(searchValue));
}
// Records after filtering
var filteredRecords = await query.CountAsync();
// Apply sorting
if (request.Sorting.Any())
{
var sort = request.Sorting.First();
var column = request.Columns[sort.ColumnIndex];
query = column.Data switch
{
"firstName" => sort.IsAscending
? query.OrderBy(e => e.FirstName)
: query.OrderByDescending(e => e.FirstName),
"lastName" => sort.IsAscending
? query.OrderBy(e => e.LastName)
: query.OrderByDescending(e => e.LastName),
"email" => sort.IsAscending
? query.OrderBy(e => e.Email)
: query.OrderByDescending(e => e.Email),
"department" => sort.IsAscending
? query.OrderBy(e => e.Department.Name)
: query.OrderByDescending(e => e.Department.Name),
_ => query.OrderBy(e => e.LastName)
};
}
// Apply pagination and project to DTO
var data = await query
.Skip(request.Start)
.Take(request.Length)
.Select(e => new EmployeeDto
{
Id = e.Id,
FirstName = e.FirstName,
LastName = e.LastName,
Email = e.Email,
Department = e.Department.Name,
HireDate = e.HireDate
})
.ToListAsync();
// Create response
var response = request.CreateResponse(totalRecords, filteredRecords, data);
return new JsonResult(response);
}
}
public class EmployeeDto
{
public int Id { get; set; }
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string Department { get; set; } = string.Empty;
public DateTime HireDate { get; set; }
}
Implement proper error handling for production scenarios:
Pages/Users.cshtml.cs
public async Task<IActionResult> OnPostDataAsync([FromBody] IDataTableRequest request)
{
try
{
if (request == null)
{
return BadRequest(new { error = "Invalid request" });
}
var query = _context.Users.AsQueryable();
// Apply filters, sorting, pagination...
var totalRecords = await _context.Users.CountAsync();
var filteredRecords = await query.CountAsync();
var data = await query
.Skip(request.Start)
.Take(request.Length)
.ToListAsync();
return new JsonResult(request.CreateResponse(totalRecords, filteredRecords, data));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing DataTables request");
// Return error response that DataTables can handle
return new JsonResult(new
{
draw = request?.Draw ?? 0,
recordsTotal = 0,
recordsFiltered = 0,
data = Array.Empty<object>(),
error = "An error occurred while processing your request"
})
{
StatusCode = 500
};
}
}
Client-side error handling:
$('#myTable').DataTable({
serverSide: true,
ajax: {
url: '@Url.Page("MyPage", "Data")',
type: 'POST',
contentType: 'application/json',
data: function (d) {
return JSON.stringify(d);
},
error: function (xhr, error, code) {
console.error('DataTables error:', error, code);
alert('Failed to load data. Please try again.');
}
}
});
IDataTableRequest InterfaceThe request object automatically bound from the DataTables Ajax payload:
| Property | Type | Description |
|---|---|---|
Draw | int | Draw counter for async ordering |
Start | int | Paging: first record index (0-based) |
Length | int | Paging: number of records to return |
Search | ISearch | Global search parameters |
Columns | IReadOnlyList<IColumn> | Column definitions and search |
Sorting | IReadOnlyList<ISort> | Sorting parameters |
AdditionalParameters | IDictionary<string, object> | Custom parameters (if configured) |
DataTableOptions Methods| Method | Description |
|---|---|
.SetDefaultPageLength(int) | Set default records per page (default: 10) |
.EnableDrawValidation() | Enable draw counter validation (recommended) |
.DisableDrawValidation() | Disable draw validation |
.UseCamelCase() | Use camelCase for DataTables 1.10+ |
.UseHungarianNotation() | Use Hungarian notation for legacy DataTables |
// Create a DataTables response
IDataTableResponse response = request.CreateResponse(
totalRecords: 100,
filteredRecords: 25,
data: myDataList
);
// With additional parameters
IDataTableResponse response = request.CreateResponse(
totalRecords: 100,
filteredRecords: 25,
data: myDataList,
additionalParameters: new Dictionary<string, object>
{
["summary"] = new { totalSales = 50000.00m }
}
);
Always project to DTOs to avoid loading unnecessary data:
// ❌ Bad: Loads entire entity
var data = await query.Skip(request.Start).Take(request.Length).ToListAsync();
// ✅ Good: Projects only needed columns
var data = await query
.Select(e => new { e.Id, e.Name, e.Email })
.Skip(request.Start)
.Take(request.Length)
.ToListAsync();
Index columns used for searching and sorting:
modelBuilder.Entity<Employee>()
.HasIndex(e => e.LastName);
modelBuilder.Entity<Employee>()
.HasIndex(e => new { e.LastName, e.FirstName });
For data that rarely changes:
private readonly IMemoryCache _cache;
public async Task<IActionResult> OnPostDataAsync([FromBody] IDataTableRequest request)
{
var cacheKey = "departments";
if (!_cache.TryGetValue(cacheKey, out List<Department> departments))
{
departments = await _context.Departments.ToListAsync();
_cache.Set(cacheKey, departments, TimeSpan.FromMinutes(30));
}
// Process cached data...
}
AsNoTracking() for Read-Only Queriesvar query = _context.Employees
.AsNoTracking() // Faster for read-only scenarios
.Where(e => e.IsActive);
Only include columns you need on the client:
// ❌ Bad: Loading many unused columns
columns:
[
{ data: 'id' },
{ data: 'fullEntity' } // Entire object
]
// ✅ Good: Only necessary columns
columns:
[
{ data: 'id' },
{ data: 'name' },
{ data: 'email' }
]
Possible causes:
@Url.Page() or endpoint is correctPOST on both client and serverSolution:
// Add error handler to see what's wrong
ajax: {
url: '@Url.Page("MyPage", "Data")',
type: 'POST',
contentType: 'application/json',
error: function (xhr, error, code) {
console.error('Ajax error:', xhr.responseText);
},
data: function (d) {
return JSON.stringify(d);
}
}
Cause: Response format doesn't match DataTables expectations
Solution: Ensure your response includes recordsTotal, recordsFiltered, and data:
// ✅ Correct: Using CreateResponse
return new JsonResult(request.CreateResponse(total, filtered, data));
// ❌ Wrong: Manual object
return new JsonResult(new { myData = data }); // Missing required properties
Cause: Draw parameter not being sent or validated incorrectly
Solutions:
builder.Services.AddDataTables(new DataTableOptions().DisableDrawValidation());
data: function (d) {
console.log('Draw:', d.draw); // Should increment each request
return JSON.stringify(d);
}
Cause: Missing contentType: 'application/json' in Ajax settings
Solution:
ajax: {
url: '@Url.Page("MyPage", "Data")',
type: 'POST',
contentType: 'application/json', // ← Required!
data: function (d) {
return JSON.stringify(d);
}
}
Cause: Mismatch between property names and data attribute
Solutions:
// Server (enable camelCase)
builder.Services.AddDataTables(new DataTableOptions().UseCamelCase());
// Client
columns:
[
{ data: 'firstName' }, // Matches FirstName property { data: 'lastName' }
]
columns:
[
{ data: 'FirstName' }, // Exact match
{ data: 'LastName' }
]
A: Yes! The library works with MVC controllers as well:
[HttpPost]
public async Task<IActionResult> GetData([FromBody] IDataTableRequest request)
{
// Same logic as Razor Pages
var data = await _service.GetDataAsync(request);
return Json(request.CreateResponse(totalRecords, filteredRecords, data));
}
A: DataTables 2.0 maintains backward compatibility with the 1.10 protocol. Use .UseCamelCase() for best compatibility.
A: While possible, POST is recommended for server-side processing due to payload size. If you must use GET:
public IActionResult OnGetData([FromQuery] IDataTableRequest request)
{
// Handler logic
}
ajax: {
url: '@Url.Page("MyPage", "Data")',
type: 'GET', // No contentType or JSON.stringify needed
}
A: DataTables export runs client-side by default. For server-side export:
public IActionResult OnGetExport()
{
var allData = _context.Employees.ToList(); // Use a library like ClosedXML, EPPlus, or iTextSharp
var excelBytes = GenerateExcel(allData);
return File(excelBytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "employees.xlsx");
}
This project is licensed under the MIT License - see the LICENSE file for details.