Data Table

A table component with sorting, filtering, and pagination compatible with VitNode API.

Preview

NameEmailRoleStatus
John Doejon_doe@mail.comAdminActive
Jane Smithjane_smith@mail.comEditorInactive
Alice Johnsonalice_johnson@mail.comViewerActive
Bob Brownbob_brown@mail.comAdminInactive

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} />,
  },
]}

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:

search-roles.tsx
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.

NameEmailRole
John Doejon_doe@mail.comAdmin
Jane Smithjane_smith@mail.comEditor
Alice Johnsonalice_johnson@mail.comViewer
Bob Brownbob_brown@mail.comAdmin

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:

delete-bulk-action.tsx
'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}
    />
  )
}