dentil

Data Table

A server-driven table built on TanStack Table. You own data fetching: the table reads and writes a single query object (filters, tab, sort and page), and you pass back data, loading, and either hasMore or the next and previous cursors.

@dentil/data-table@dentil/data-table-filter@dentil/data-table-url-state
On this page

Installation

Add the @dentil registry to components.json (see Get started), then install the pieces you need. Without it, swap each name for its URL, e.g. https://dentil.dev/r/data-table.json. The table uses stock shadcn/ui components, Radix or Base UI, which the CLI installs if they're missing.

pnpm dlx shadcn@latest add @dentil/data-table
npx shadcn@latest add @dentil/data-table
yarn dlx shadcn@latest add @dentil/data-table
bunx --bun shadcn@latest add @dentil/data-table
pnpm dlx shadcn@latest add @dentil/data-table-filter
npx shadcn@latest add @dentil/data-table-filter
yarn dlx shadcn@latest add @dentil/data-table-filter
bunx --bun shadcn@latest add @dentil/data-table-filter
pnpm dlx shadcn@latest add @dentil/data-table-url-state
npx shadcn@latest add @dentil/data-table-url-state
yarn dlx shadcn@latest add @dentil/data-table-url-state
bunx --bun shadcn@latest add @dentil/data-table-url-state

Files land in components/data-table/. Requires React 19, Tailwind v4 and @tanstack/react-table v8.

Usage

'use client';

import { createColumnHelper } from '@tanstack/react-table';
import { useState } from 'react';

import { DataTable } from '@/components/data-table/data-table';
import { DateCell } from '@/components/data-table/date-cell';
import type { TableQuery } from '@/components/data-table/types';

type Customer = { id: string; name: string; email: string; created: number };

const col = createColumnHelper<Customer>();

const columns = [
  col.accessor('name', { header: 'Name', enableHiding: false }),
  col.accessor('email', { header: 'Email' }),
  col.accessor('created', {
    header: 'Created',
    cell: ({ getValue }) => <DateCell date={getValue()} />,
    meta: { align: 'right', minimize: true },
  }),
];

export function CustomersTable() {
  const [query, setQuery] = useState<TableQuery>({});
  // Any data layer works: SWR, React Query, a server action… SWR and React Query cache by key, so returning to a
  // tab, page or filter you've already loaded is instant.
  const { data, isLoading, isValidating } = useSWR(['/api/customers', query], fetcher, { keepPreviousData: true });

  return (
    <DataTable
      columns={columns}
      data={data?.data ?? []}
      hasMore={data?.has_more}
      loading={isLoading}
      fetching={isValidating}
      query={query}
      setQuery={setQuery}
      url={(customer) => `/customers/${customer.id}`}
      sortableColumns={{ created: 'created_at' }}
      showPagination
    />
  );
}

The query contract

Everything the user changes ends up in query. Send it to your API as-is, or map it. Tabs and filters keep each other's keys, and any change except paging resets the page.

// What the table writes into `query`:
{
  offset: 2,                    // zero-based page index (Previous / Next, ← →)
  sort: 'ascending',            // or 'descending'
  sort_field: 'created_at',     // value from sortableColumns
  status: 'open',               // tab queries and filters, keyed by id
  amount: { gte: 1000, lte: 5000 },
}

Cursor pagination

If your API pages with cursors, pass back the cursors from each response instead of hasMore.

Next writes nextCursor into query.after, and Previous writes previousCursor into query.before. Only one of after and before is set at a time, and offset is never written. A null cursor disables its button. Filter, tab and sort changes drop both cursors, so the user goes back to the first page.

const { data, isLoading } = useSWR(['/api/customers', query], fetcher);

<DataTable
  data={data?.data ?? []}
  nextCursor={data?.next_cursor}          // null on the last page
  previousCursor={data?.previous_cursor}  // null on the first page
  {...props}
/>

// Next     → { ...query, after: data.next_cursor }
// Previous → { ...query, before: data.previous_cursor }

Routing & frameworks

The table has no router dependency. Wrap the app once in DataTableRouter and every table below it uses that link, prefetch and navigation. A table can still pass linkComponent, onNavigate or onPrefetch to override it. With no provider, row links render a plain <a> and programmatic navigation uses window.location.assign.

Next.js
// components/table-router.tsx
'use client';

import Link from 'next/link';
import { useRouter } from 'next/navigation';

import { DataTableRouter } from '@/components/data-table/router';

export function TableRouter({ children }: { children: React.ReactNode }) {
  const router = useRouter();

  return (
    <DataTableRouter linkComponent={Link} onNavigate={router.push} onPrefetch={router.prefetch}>
      {children}
    </DataTableRouter>
  );
}
React Router
// components/table-router.tsx
'use client';

import { Link, useNavigate } from 'react-router';

import { DataTableRouter } from '@/components/data-table/router';
import type { DataTableLinkProps } from '@/components/data-table/types';

// React Router's Link takes `to`, so adapt it once:
const RouterLink = ({ href, ...props }: DataTableLinkProps) => <Link to={href} {...props} />;

export function TableRouter({ children }: { children: React.ReactNode }) {
  return (
    <DataTableRouter linkComponent={RouterLink} onNavigate={useNavigate()}>
      {children}
    </DataTableRouter>
  );
}

Examples

Columns

The demos below share these definitions: a custom amount cell, a status badge, mono text, DateCell, an actions menu, and column meta for alignment and width.

lib/demo/columns.tsx
'use client';

import { createColumnHelper } from '@tanstack/react-table';

import { ActionsButton } from '@/components/data-table/actions-button';
import { DateCell } from '@/components/data-table/date-cell';
import { cn } from '@/lib/utils';

import { type Invoice, type InvoiceStatus, formatAmount } from './invoices';

const STATUS_STYLES: Record<InvoiceStatus, string> = {
  draft: 'bg-muted text-muted-foreground',
  open: 'bg-blue-500/10 text-blue-700 dark:text-blue-300',
  paid: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
  void: 'bg-muted text-muted-foreground line-through',
};

export function StatusBadge({ status }: { status: InvoiceStatus }) {
  return (
    <span
      className={cn(
        'inline-flex h-5 items-center justify-center self-center rounded-md px-1.5 text-xs leading-none font-medium capitalize',
        STATUS_STYLES[status]
      )}>
      {status}
    </span>
  );
}

const col = createColumnHelper<Invoice>();

export const invoiceColumns = [
  col.accessor('amount', {
    id: 'amount',
    header: 'Amount',
    enableHiding: false,
    cell: ({ row }) => (
      <div className="flex items-center gap-2">
        <span className="font-medium tabular-nums">{formatAmount(row.original.amount)}</span>
      </div>
    ),
    meta: { minimize: true },
  }),
  col.accessor('status', {
    id: 'status',
    header: 'Status',
    cell: ({ getValue }) => <StatusBadge status={getValue()} />,
    meta: { minimize: true },
  }),
  col.accessor('number', {
    id: 'number',
    header: 'Number',
    cell: ({ getValue }) => <span className="font-mono text-xs leading-6.5">{getValue()}</span>,
  }),
  col.accessor((row) => row.customer.name, { id: 'customer', header: 'Customer' }),
  col.accessor((row) => row.customer.email, {
    id: 'email',
    header: 'Email',
    cell: ({ getValue }) => <span className="text-muted-foreground">{getValue()}</span>,
  }),
  col.accessor('due', {
    id: 'due',
    header: 'Due',
    cell: ({ getValue }) => <DateCell date={getValue()} showTime={false} />,
    meta: { minimize: true },
  }),
  col.accessor('created', {
    id: 'created',
    header: 'Created',
    cell: ({ getValue }) => <DateCell date={getValue()} align="right" />,
    meta: { align: 'right', minimize: true },
  }),
  col.display({
    id: 'actions',
    enableHiding: false,
    cell: ({ row }) => (
      <ActionsButton
        actions={[
          { name: 'Copy number', onClick: () => navigator.clipboard.writeText(row.original.number) },
          { name: 'Void invoice', variant: 'destructive', onClick: () => {}, disabled: row.original.status === 'void' },
        ]}
      />
    ),
    meta: { minimize: true },
  }),
];

Links, sorting and pagination

Rows are links and prefetch on hover, from the DataTableRouter in the root layout. Sort by Created or Due. Use ← → to change page.

Tabs, column visibility and toolbar actions

Tabs merge their query with existing filters. Display toggles any column without enableHiding: false.

Selection and context menu

Select across pages, press A to select the whole page, and right-click a row. Void invoices can't be selected.

Split view

Click a row, or use ↑ ↓, to show its details. On mobile, rows navigate instead.

Expandable rows

Open two rows with their chevrons; both stay open. Clicking anywhere else on a row still follows its link. Drafts can't be expanded.

Expand on row click

With trigger: 'row', clicking anywhere on a row opens its panel, and the row isn't a link. Drafts can't expand, so they still link. Tab to a chevron to toggle a row from the keyboard.

Filters

@dentil/data-table-filter adds a Linear-style filter bar (adapted from bazza/ui) that reads and writes the same query. Try the async Customer filter and a number range, then reload the page: the filters come back from the URL.

import { createColumnConfigHelper } from '@/components/data-table/filter';
import { DataTableFilters } from '@/components/data-table/filters';

const dtf = createColumnConfigHelper<Invoice>();

const filterColumns = [
  dtf.text().id('email').accessor((r) => r.email).displayName('Email').icon(Mail).build(),
  dtf
    .option()
    .id('status')
    .accessor((r) => r.status)
    .displayName('Status')
    .icon(CircleDot)
    .options([{ label: 'Open', value: 'open' }, { label: 'Paid', value: 'paid' }])
    .allowMultiple()
    .build(),
  dtf
    .option()
    .id('customer')
    .accessor((r) => r.customer)
    .displayName('Customer')
    .icon(User)
    .asyncOptions({
      fetchOptions: (search) => api.searchCustomers(search),  // => { label, value }[]
      resolveLabel: (id) => api.getCustomerName(id),           // for values restored from the URL
    })
    .build(),
  dtf.number().id('amount').accessor((r) => r.amount).displayName('Amount').icon(PoundSterling).scale(100).build(),
] as const;

<DataTable
  toolbar={<DataTableFilters data={data} filterColumns={filterColumns} query={query} setQuery={setQuery} />}
  {...props}
/>

Query values by filter type

// text     → email: 'ada'
// option   → status: 'open'  |  status: ['open', 'paid']
// number   → amount: 1500 | { gt } | { gte } | { lt } | { lte } | { ne }
//            | { gte, lte }          (between)
//            | { nbt: { gte, lte } } (not between)
// .scale(100) multiplies number values on the way out, so users type £15 and the API gets 1500.

URL state

@dentil/data-table-url-state mirrors the query to the URL with history.replaceState, so links are shareable, reloads keep the view, and Back from a detail page returns to the same filters, without adding history entries or triggering router navigations.

import { useTableQueryState } from '@/components/data-table/use-table-query-state';

// Drop-in replacement for useState<TableQuery>.
const [query, setQuery] = useTableQueryState({ sort: 'descending' }, { urlState: true });

// Several tables on one page? Namespace them:
const [invoiceQuery, setInvoiceQuery] = useTableQueryState({}, { urlState: { prefix: 'inv.' } });

// Server-rendered (Next.js)? Pass the search string so the first render matches:
const searchParams = useSearchParams();
const [query, setQuery] = useTableQueryState({}, { urlState: true, search: searchParams.toString() });

API reference

DataTableRouter

Wrap the app once. Props on a table override these.

PropTypeDefaultDescription
linkComponentElementType<DataTableLinkProps>—Link used for rows and “Show more”. Pass next/link or an adapted React Router Link.
onNavigate(url: string) => void—Programmatic navigation, used on mobile when split view is on. For example router.push.
onPrefetch(url: string) => void—Called when a linked row is hovered, debounced 100ms, once per URL. For example router.prefetch.

DataTable

PropTypeDefaultDescription
columnsColumnDef<TData, any>[]—TanStack column definitions.
dataTData[]—Rows for the current page.
query / setQueryTableQuery—Controlled query. See the query contract.
loadingboolean—Show the loading state instead of rows.
fetchingboolean—Fade the current rows while the next query loads, e.g. SWR’s isValidating or React Query’s isFetching.
hasMoreboolean—Enables “Next” for offset pagination.
nextCursorstring | null—Cursor pagination. “Next” writes it into query.after; null disables “Next”. Overrides hasMore.
previousCursorstring | null—Cursor pagination. “Previous” writes it into query.before; null disables “Previous”.
getRowId(row) => stringrow.idIdentity for selection and split view.
url(row) => string | undefined—Turn rows into links.
onRowClick(row) => void—Click handler for rows (with or without url).
linkComponentElementType<DataTableLinkProps>'a'Link used for rows and “Show more”. Overrides DataTableRouter.
onPrefetch(url) => void—Called on row hover, debounced 100ms, once per URL. Overrides DataTableRouter.
onNavigate(url) => voidlocation.assignProgrammatic navigation (mobile split view). Overrides DataTableRouter.
toolbarReactNode—Left side of the toolbar, usually <DataTableFilters />.
toolbarActionsReactNode—Right side of the toolbar, e.g. an export button.
showDisplaybooleanfalseColumn visibility dropdown.
tabsTableTab[]—{ label, query }[]; selecting a tab merges its query.
tabsOptions{ allPosition?, showAll? }{ 'end', true }Placement of the “All” tab.
onTabChange(tab) => void—Called after a tab is selected.
showPaginationbooleanfalsePrevious/Next footer and ← → hotkeys.
showMorestring | null—“Show more” link in the footer instead of pagination.
sortableColumnsRecord<string, string>—Column id → API sort field.
contextMenuActionsAction[] | (row) => Action[]—Right-click menu per row.
splitView{ component: (row | null) => ReactNode }—Detail panel beside the table; ↑ ↓ move between rows.
expandable{ component: (row) => ReactNode, canExpand?: (row) => boolean, trigger?: 'chevron' | 'row' }—Adds a chevron column at the end of each row. Each chevron opens a full-width panel under its row, and several rows can be open. canExpand returning false hides a row’s chevron. trigger: 'row' makes the whole row toggle it instead of following url.
enableSelectionbooleanfalseCheckbox column and floating action bar.
selectedRows / onSelectionChangeTData[]—Controlled selection; persists across pages.
selectedRowFilter(row) => boolean—Return false to disable a row’s checkbox.
selectionActionsSelectionAction<TData>[]—Buttons in the selection bar.
renderSelectionStatus({ selectedRows, dismiss }) => ReactNode—Replace the bar with custom status, e.g. bulk-job progress.
customRowClassName(row) => string | undefined—Per-row classes.
noResultsTextstring'No results'Empty message.
emptyState / loadingStateReactNode—Replace the empty or loading UI.
metaTableMeta<TData>—Passed to TanStack; read it in cells via table.options.meta.

Column meta

Typed via module augmentation of TanStack's ColumnMeta.

col.accessor('amount', {
  header: 'Amount',
  meta: {
    align: 'right',          // right-align header and cell
    minimize: true,          // shrink the column to its content (w-px)
    itemAlign: 'center',     // vertically centre the cell content
    disableRowLink: true,    // the cell renders its own link/button, so leave it out of the row link
  },
});

SelectionAction

PropTypeDefaultDescription
labelstring—Button text.
onClick(rows) => void | Promise<void>—Receives every selected row. A promise shows a spinner until it settles.
variantButton variant'default'shadcn Button variant.
iconComponentType—Leading icon.
disabled / hiddenboolean | (rows) => boolean—Conditionally disable or hide.
clearOnSuccessbooleanfalseClear the selection after onClick resolves.

Helpers

ActionsButton (row … menu), DateCell (unix seconds, ms, ISO or Date), createCommonActions / createCustomAction / createSubMenu / createSeparator / createLabel for context menus, and the table primitives in primitives.tsx.