Data Table
A table component with sorting, filtering, and pagination compatible with VitNode API.
Preview
| Name | Role | Status | ||
|---|---|---|---|---|
| John Doe | jon_doe@mail.com | Admin | Active | |
| Jane Smith | jane_smith@mail.com | Editor | Inactive | |
| Alice Johnson | alice_johnson@mail.com | Viewer | Active | |
| Bob Brown | bob_brown@mail.com | Admin | Inactive |
Showing 1–1 of 1
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 returns you to the first page.
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. The universal fetcher answers from the browser here, with the visitor's own cookies:
import type { FilterOption } from '@vitnode/core/components/table/filters'
import { RoleFormat } from '@vitnode/core/components/role-format'
import { fetcher } from '@vitnode/core/tanstack/fetcher'
export const searchRoles = async (search: string): Promise<FilterOption[]> => {
const res = await fetcher({
plugin: '@vitnode/core',
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,Pagination
The table renders its own pager from pageInfo — numbered pages with a first/last
anchor and an ellipsis in between, a rows-per-page select, and a
"Showing 21–30 of 380" range. You wire up nothing: pass pageInfo through and it
appears.
Each page is a real link (?page=3), so middle-click and "open in new tab" work,
while a plain click stays a client-side navigation. On phones the numbers collapse
to a "Page 3 of 38" label between the arrows, because forty tap targets do not fit
on a 390px screen.
Two details worth knowing:
- Changing the page size or a filter returns you to page one. A filter changes which rows exist, so the page you were on no longer means what it meant.
- A page past the end lands on the last page, not on an empty table. Stale links and hand-typed numbers stay useful.
The API does the counting. See Pagination for the route side, including the index your ordered column needs.
Bulk Actions
Pass bulkActions to let a person act on several rows at once. That single prop is what turns selection on: the table grows a leading checkbox column - one per row, plus a header checkbox that ticks the whole page - and while anything is ticked, a bar floats at the bottom centre of the viewport with the count, your actions, and a button to clear the selection.
| Name | Role | ||
|---|---|---|---|
| John Doe | jon_doe@mail.com | Admin | |
| Jane Smith | jane_smith@mail.com | Editor | |
| Alice Johnson | alice_johnson@mail.com | Viewer | |
| Bob Brown | bob_brown@mail.com | Admin |
Showing 1–4 of 4
Your actions read the ticked ids with the useDataTableSelection hook. They are rendered inside the bar, which lives inside the table's selection provider, so nothing has to be threaded through props:
'use client'
import { useDataTableSelection } from '@vitnode/core/components/table/selection'
import { Button } from '@vitnode/core/components/ui/button'
export const DeleteBulkAction = () => {
const { clear, selected } = useDataTableSelection()
return (
<Button
onClick={() => deleteUsersAction({ ids: selected })}
variant="destructive"
>
<Trash2Icon />
Delete
</Button>
)
}<DataTable
id="users-table"
bulkActions={<DeleteBulkAction />}
columns={[
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'email', header: 'Email' },
]}
edges={data.edges}
pageInfo={data.pageInfo}
order={{
defaultOrder: {
column: 'name',
order: 'asc',
},
}}
/>What the hook returns
Prop
Type
Selection is per page
The selection only ever covers the rows currently on screen. Whenever the server sends a different set of row ids - paging, searching, changing a filter - the selection is pruned to what is still there, so paging away empties it on its own and a bulk action can never touch a row nobody can see.
That pruning is also what makes a partly-successful action readable: revalidate after deleting 3 of 5 rows and the 2 that were refused stay on screen and stay ticked, so the bar is still pointed at exactly the work that is left.
Bulk actions run per row
There is no bulk endpoint behind bulkActions - it hands you ids, and what
you do with them is yours. When your action loops the single-row endpoint, cap
the fan-out and report the outcomes per reason rather than as one pass/fail,
so a run that partly succeeded can say so.
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 { fetcher } from '@vitnode/core/tanstack/fetcher'
export const UsersView = async ({
searchParams,
}: {
searchParams: Promise<SearchParamsDataTable>
}) => {
const query = await searchParams
const res = await fetcher({
plugin: '@vitnode/core',
path: '/list',
method: 'get',
module: 'admin/users',
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}
/>
)
}