TanStack Table with toolbar, filters, selection, and optional URL sync.
data-table is a listing kit built on TanStack Table. It bundles toolbar search, column filters, sorting, pagination, row selection, bulk actions, optional URL sync (nuqs), and persisted column preferences.
When to use it
- Admin catalogs, IAM lists, audit logs, and any screen with search + filters + paging
- Flows that need row selection and a bulk action bar (export, delete, assign)
- Listings that should share state in the URL (page, limit, sort, filters) for refresh-safe bookmarks
Skip URL sync (syncUrl={false}) for embedded tables on detail pages or docs previews.
Installation
Registry CLI
Requires a one-time components.json registry entry for @feature-kit/data-table.
pnpm dlx shadcn@latest add https://feature-kits.vercel.app/r/data-table.jsonNamespaced: npx shadcn@latest add @feature-kit/data-table
Manual installation
Monorepo consumers can depend on workspace:* packages instead of the registry CLI.
pnpm add data-table layout form uiDependencies
// apps/your-app/package.json
"dependencies": {
"data-table": "workspace:*"
"layout": "workspace:*"
"form": "workspace:*"
"ui": "workspace:*"
}Transpile (Next.js)
// next.config.ts
transpilePackages: ["data-table", "layout", "form", "ui"]Import
import type { ColumnDef } from "@/lib/feature-kit/data-table"
import {
DataTable,
DataTableColumnHeader,
DataTableNuqsAdapter,
commonColumns,
} from "@/lib/feature-kit/data-table"Wrap your app with DataTableNuqsAdapter when syncUrl is true. ConfirmationDialogProvider from layout is required for destructive bulk actions.
Mount providers once at the app root (see apps/web/src/app/providers.tsx):
<DataTableNuqsAdapter>
<ConfirmationDialogProvider>{children}</ConfirmationDialogProvider>
</DataTableNuqsAdapter>Usage
Minimal table
Columns
DataTable accepts TanStack ColumnDef objects. Keep the row type explicit and give
the table a stable displayKey for toolbar search. Use accessor columns for values
that can be sorted or filtered, and display columns for controls such as selection
and row actions.
const columns: ColumnDef<Account>[] = [
...commonColumns.selectColumn<Account>(),
{
accessorKey: "name",
header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
},
]- Use
DataTableColumnHeaderfor sortable labels. - Import
commonColumns.selectColumn()for the selection checkbox column. - Declare filters in column
meta.filteror passfilterDefinitionsfor toolbar-only filters. - Pass
filterOptionsfrom the server for distinct facet values.
Toolbar and filters
The built-in toolbar can contain search, column filters, active filter chips, an
add-filter menu, view options, and custom controls. Add custom controls with
toolbarLeading, toolbarAfterSearch, or toolbarTrailing.
Use filterDefinitions when a filter does not map to a visible column. Keep
filterOptions keyed by the filter key (meta.filter.key, or the column id).
Options should represent the full result set, not only the current page.
Sorting and pagination
Sorting and pagination are client-side when data contains the complete collection.
For server-driven listings, return only the requested slice and pass both
rowCount and pageCount. The host application owns the request and maps table
state to its API query.
<DataTable
tableId="accounts"
columns={columns}
data={pageRows}
displayKey="name"
rowCount={total}
pageCount={Math.ceil(total / limit)}
syncUrl
urlServerRefresh
/>URL synchronization
When syncUrl={true} (default):
page,limit, sort, search, and filter keys are written to the query string.- Client listings read params via
useEffectiveSearchParamsinside the kit. - Set
urlServerRefresh={true}on server-driven pages so RSC refetches after shallow updates.
Use DataTableNuqsAdapter once above tables that use URL synchronization.
syncUrl defaults to true; set syncUrl={false} for embedded tables. Keep
urlShallow enabled for shallow query-string updates, and add urlServerRefresh
when a server component must refetch after the update. canonicalizeUrl={false}
prevents initial page and limit values from being written to the URL.
Selection and bulk actions
Add commonColumns.selectColumn() and provide a stable selectionKey when rows
can remain selected across pages. enableRowSelection can disable selection
globally or return false for individual rows. Compose BulkActionBar with
BulkClearSelectionAction, BulkExportCsvAction, or BulkExportXlsxAction through
the bulkActions prop. Destructive actions require the layout
ConfirmationDialogProvider.
For custom workflows, useCrossPageSelection({ selectionKey, data }) returns
selectedRows, selectedCount, rowSelection, onRowSelectionChange,
getRowId, and clearSelection. Send selected stable IDs to the server; do not
assume the current data array contains every selected row.
Row actions
Use RowActionsMenu with RowActionView, RowActionEdit, and RowActionDelete
for common per-row operations. Keep authorization and mutation requests in the
host application, and confirm destructive operations before sending them.
Expanded rows
Pass renderExpanded to render detail content beneath a row. Use
getRowCanExpand to restrict expansion, showExpandColumn={false} to remove the
per-row chevron, and initialExpanded to control the initial state. The callback
receives the TanStack row, so use row.original for domain data.
<DataTable
{...tableProps}
renderExpanded={(row) => <AccountActivity accountId={row.original.id} />}
getRowCanExpand={(row) => row.original.activityCount > 0}
/>View options and persistence
DataTable shows column visibility controls by default. Use
initialColumnVisibility for defaults and hideViewOptions when the table should
not expose them. A tableId enables persisted column preferences in local storage;
use a stable, page-specific value and avoid reusing it for different row shapes.
DataTableViewOptions is also exported for custom compositions.
Fullscreen
Set fullscreen to true or pass { title } to add the fullscreen toggle. The
kit provides DataTableFullscreenToggle, DataTableFullscreenDialog, and
DataTableFullscreenPlaceholder for custom compositions. Fullscreen is useful
for wide operational lists; keep an accessible exit control.
Loading, empty, and error states
Use isPending for an initial fetch with no rows and isFetching for a refetch
where the existing table should remain recognizable. skeletonVariant can be
"card" for card-style rows. Pass an emptyState with title, description,
and an optional { label, url } action for a no-results state.
DataTableLoading and DataTableError are public exports for page-level fallbacks.
Distinguish a filtered-empty result from an unavailable or failed request, and put
retry or clear-filter actions in the host application.
Server-driven data
Server-driven tables still use a client DataTable for interaction. The host
application should read query parameters, fetch authorized data, calculate
rowCount and pageCount, and pass the current slice to the table. Add
urlServerRefresh when URL changes must trigger an App Router server refresh.
The kit does not own API calls, authorization, query construction, or business rules.
Recipes
| Recipe | Example |
|---|---|
| Toolbar filters | Filters |
| Bulk export bar | Bulk actions |
| Row actions menu | Row actions |
| Loading / empty | Loading, Empty |
Server-driven pagination
Pass pageCount and rowCount when data is paged on the server. The kit uses manual pagination; your loader fetches the current slice.
<DataTable
tableId="accounts"
columns={columns}
data={pageRows}
rowCount={total}
pageCount={Math.ceil(total / limit)}
displayKey="name"
syncUrl={true}
urlServerRefresh={true}
/>Embedded table
<DataTable
tableId="audit-on-detail"
columns={columns}
data={rows}
displayKey="action"
syncUrl={false}
hideViewOptions
/>API reference
DataTable (core)
| Prop | Type | Default | Description |
|---|---|---|---|
| columns | ColumnDef<TData>[] | — | TanStack column definitions. |
| data | TData[] | — | Rows for the current page or full client list. |
| tableId | string | — | Persists column visibility in localStorage. |
| displayKey | keyof TData | string | — | Column id used for toolbar search. |
| selectionKey | (row) => string | — | Stable id for cross-page selection. |
| enableRowSelection | boolean | (row) => boolean | true | Disable all selection or per row. |
| bulkActions | ReactNode | — | Usually BulkActionBar + actions. |
| pageCount | number | — | Total pages when using server pagination. |
| rowCount | number | — | Total rows across pages. |
| syncUrl | boolean | true | Sync table state to the URL. |
| urlShallow | boolean | true | Shallow routing for URL updates. |
| urlServerRefresh | boolean | false | Trigger RSC refresh after URL writes. |
| filterOptions | Record<string, ColumnFilterOption[]> | — | Facet options keyed by filter id. |
| filterDefinitions | DataTableFilterDefinition[] | — | Toolbar filters without column meta. |
| emptyState | DataTableEmptyState | — | Custom empty UI. |
| isFetching / isPending | boolean | — | Show body skeleton during refetch or initial load. |
Toolbar & display helpers
| Export | Role |
|---|---|
DataTableToolbar | Composed inside the kit; override panels via toolbar* className props |
DataTableViewOptions | Column visibility manager |
DataTablePagination | Footer pager (used internally) |
BulkActionBar, BulkExportAction | Selection chrome |
useCrossPageSelection | Read selection across pages |
Gotchas
filterOptions scope
Distinct filter values must come from the full dataset (or server facets), not from data on the current page—otherwise options shrink as users paginate.
Nuqs adapter
Without DataTableNuqsAdapter, URL sync hooks throw at runtime. Add the adapter before any syncUrl={true} table mounts.
Suspense
DataTable is wrapped in Suspense for search params. Provide a fallback or parent Suspense boundary on App Router pages.