Data Table
A table component with sorting, filtering, and pagination compatible with VitNode API.
Preview
Usage
import {
type ColumnDef,
DataTable,
} from "@vitnode/core/components/table/data-table";Columns are typed with ColumnDef<T>, where T is the shape of a single row. An accessor column binds to the data through accessorKey, which is type-safe - it only accepts a keyof T, just like TanStack Table. A display column (for actions, selection, etc.) has no accessorKey and instead requires a string id.
const columns: ColumnDef<User>[] = [
{ accessorKey: "name", header: "Name" },
{ accessorKey: "email", header: "Email" },
{ accessorKey: "role", header: "Role" },
{ accessorKey: "status", header: "Status", align: "center" },
{
id: "actions",
header: "",
align: "right",
cell: () => (
<Button size="sm" variant="outline">
Edit
</Button>
),
},
];
<DataTable
id="users-table"
columns={columns}
edges={data.edges}
pageInfo={data.pageInfo}
order={{
defaultOrder: {
column: "name",
order: "asc",
},
}}
/>;Type-safe columns
accessorKey autocompletes and is checked against the row type T - passing a
key that doesn't exist on T is a compile-time error. Use id (a plain
string) for display columns that aren't backed by a data field.
Prop
Type
Cell Renderer
You can customize how each cell is rendered using the cell property. The renderer function receives the current row data and all table data as parameters.
<DataTable
id="users-table"
columns={[
{
accessorKey: "id",
header: "Id",
cell: ({ row, allData }) => (
<span>
{row.id} - all data {allData.length}
</span>
),
},
{ accessorKey: "createdAt", header: "Created at" },
]}
edges={data.edges}
pageInfo={data.pageInfo}
order={{
columns: ["createdAt", "id"],
defaultOrder: {
order: "desc",
},
}}
/>Order Configuration
If you want to enable sorting on specific columns, you can specify them in the columns property.
order={{
columns: ['createdAt', 'id', 'name'],
defaultOrder: {
column: 'createdAt',
order: 'desc',
}
}}Column Alignment
Use the align property to control the horizontal alignment of a column's header and cells. It accepts "left" (default), "center", or "right". This is handy for numeric values and action buttons.
columns={[
{ accessorKey: "name", header: "Name" },
{ accessorKey: "status", header: "Status", align: "center" },
{
id: "actions",
header: "",
align: "right",
cell: ({ row }) => <RowActions id={row.id} />,
},
]}Search
Set the search prop to true to render a search input above the table. You can optionally customize the placeholder with searchPlaceholder.
<DataTable
id="users-table"
search
searchPlaceholder="Search users..."
columns={[
{ accessorKey: "name", header: "Name" },
{ accessorKey: "email", header: "Email" },
]}
edges={data.edges}
pageInfo={data.pageInfo}
order={{
defaultOrder: {
column: "name",
order: "asc",
},
}}
/>The input writes the term to the ?search= query parameter (debounced) and reloads the page, so it works out of the box with server-side data fetching. The columns that are actually searched are defined on the API route.
Configure the API route
Enabling search only renders the input. You must also tell the backend which
columns to search across - see the Search guide.
Filters
Pass the filters prop to render one or more faceted, multi-select dropdowns above the table (next to the search input). Each filter controls its own URL query parameter, so it works out of the box with server-side data fetching. Multiple selected values are stored as a comma-separated list, e.g. ?roleId=1,3, and changing a filter resets the pagination cursor.
Prop
Type
Static filters
When the set of options is small and known ahead of time, pass them directly via options. The dropdown list is filtered on the client.
<DataTable
id="users-table"
filters={[
{
id: "status",
label: "Status",
options: [
{ value: "active", label: "Active" },
{ value: "banned", label: "Banned" },
],
},
]}
columns={[
{ accessorKey: "name", header: "Name" },
{ accessorKey: "email", header: "Email" },
]}
edges={data.edges}
pageInfo={data.pageInfo}
order={{
defaultOrder: {
column: "name",
order: "asc",
},
}}
/>Async filters
When the options come from the API (for example a large or searchable list), provide an onSearch callback instead of options. It runs - debounced - as the user types and should return results already filtered and capped by the server. Use a Server Action so the lookup runs server-side:
"use server";
import type { FilterOption } from "@vitnode/core/components/table/filters";
import { adminModule } from "@/api/modules/admin/admin.module";
import { RoleFormat } from "@vitnode/core/components/role-format";
import { fetcher } from "@vitnode/core/lib/fetcher";
export const searchRoles = async (search: string): Promise<FilterOption[]> => {
const res = await fetcher(adminModule, {
path: "/list",
method: "get",
module: "admin/roles",
args: {
query: { search, first: "20" },
},
withPagination: true,
});
if (res.status !== 200) {
return [];
}
const data = await res.json();
return data.edges.map(role => ({
value: String(role.id),
label: <RoleFormat role={role} />,
keywords: role.name.map(item => item.name),
}));
};filters={[
{
id: "roleId",
label: "Group",
onSearch: searchRoles,
},
]}Configure the API route
A filter only writes its values to the URL - the backend must read the query
parameter and apply the matching where clause. For a comma-separated
multi-select, split the value and use inArray:
const roleIds = (query.roleId?.split(",") ?? [])
.filter(Boolean)
.map(Number)
.filter((id) => !Number.isNaN(id));
// pass to withPagination:
where: roleIds.length ? inArray(core_users.roleId, roleIds) : undefined,Complete Example
Here's a complete example showing how to use the DataTable component in a page:
import {
DataTable,
SearchParamsDataTable,
} from "@vitnode/core/components/table/data-table";
import { userModule } from "@/api/modules/user/user.module";
import { fetcher } from "@vitnode/core/lib/fetcher";
export const UsersView = async ({
searchParams,
}: {
searchParams: Promise<SearchParamsDataTable>;
}) => {
const query = await searchParams;
const res = await fetcher(userModule, {
path: "/users",
method: "get",
module: "user",
args: {
query,
},
withPagination: true,
});
const data = await res.json();
return (
<DataTable
id="users-table"
columns={[
{
accessorKey: "id",
header: "ID",
},
{
accessorKey: "username",
header: "Username",
cell: ({ row }) => (
<span className="font-medium">{row.username}</span>
),
},
{ accessorKey: "email", header: "Email" },
{ accessorKey: "createdAt", header: "Created at" },
]}
edges={data.edges}
order={{
columns: ["id", "username", "email", "createdAt"],
defaultOrder: {
column: "createdAt",
order: "desc",
},
}}
pageInfo={data.pageInfo}
/>
);
};