Paginated Data Table
A server-paginated table. Page, size and search live in the URL, so a filtered view is shareable and survives a refresh.
'use client'
import * as React from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { NuqsAdapter } from 'nuqs/adapters/next/app'
import type { ColumnDef } from '@tanstack/react-table'
import { PaginatedDataTable } from '@/components/custom/paginated-data-table'
import { formatMoney } from '@/lib/kiota-format'
import type { PaginatedApiResponse, SearchMethodParams } from '@/lib/kiota-pagination'
interface Employee {
id: string
employeeNo: string
fullName: string
branch: string
salary: number
}
const EMPLOYEES: Employee[] = Array.from({ length: 47 }, (_, index) => ({
id: `emp-${index + 1}`,
employeeNo: `EMP-${String(index + 1).padStart(3, '0')}`,
fullName: ['Ada Lovelace', 'Grace Hopper', 'Wanjiru Kamau', 'Otieno Odhiambo', 'Amina Yusuf'][index % 5],
branch: ['Nairobi', 'Mombasa', 'Kisumu'][index % 3],
salary: 45000_00 + index * 1_250_00,
}))
// Stands in for your endpoint. Note the zero-based `page` and the
// PaginatedApiResponse shape the table reads.
async function searchEmployees({ page, size, search }: SearchMethodParams): Promise<PaginatedApiResponse<Employee>> {
const term = search.trim().toLowerCase()
const matches = term
? EMPLOYEES.filter((employee) =>
`${employee.employeeNo} ${employee.fullName} ${employee.branch}`.toLowerCase().includes(term),
)
: EMPLOYEES
const start = page * size
return {
status: 200,
message: 'OK',
data: {
content: matches.slice(start, start + size),
page,
size,
totalElements: matches.length,
totalPages: Math.max(Math.ceil(matches.length / size), 1),
last: start + size >= matches.length,
},
}
}
const columns: ColumnDef<Employee>[] = [
{ accessorKey: 'employeeNo', header: 'Employee No' },
{ accessorKey: 'fullName', header: 'Name' },
{ accessorKey: 'branch', header: 'Branch' },
{
accessorKey: 'salary',
header: 'Salary',
cell: ({ row }) => <span className="tabular-nums">{formatMoney(row.original.salary)}</span>,
},
]
export default function PaginatedDataTableDemo() {
const [client] = React.useState(() => new QueryClient())
const [selected, setSelected] = React.useState<string[]>([])
return (
<QueryClientProvider client={client}>
<NuqsAdapter>
<div className="w-full space-y-3">
<PaginatedDataTable
columns={columns}
searchKeys={['employeeNo', 'fullName', 'branch']}
searchMethod={searchEmployees}
tableKey="demo-employees"
// Renamed so the demo does not fight the docs page's own URL.
queryParams={{ search: 'demoSearch', page: 'demoPage', size: 'demoSize' }}
selectedItems={selected}
setSelectedItems={setSelected}
bodyClass="max-h-80"
/>
<p className="text-sm text-muted-foreground">
{selected.length} selected — page, size and search are in this page's URL.
</p>
</div>
</NuqsAdapter>
</QueryClientProvider>
)
}Installation
pnpm dlx shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/paginated-data-table.jsonnpx shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/paginated-data-table.jsonyarn dlx shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/paginated-data-table.jsonbunx --bun shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/paginated-data-table.jsonAdds @tanstack/react-table, @tanstack/react-query and nuqs, plus lib/kiota-pagination.ts.
Setup
Needs both providers above it:
'use client'
import { QueryClientProvider } from '@tanstack/react-query'
import { NuqsAdapter } from 'nuqs/adapters/next/app'
export function Providers({ client, children }) {
return (
<QueryClientProvider client={client}>
<NuqsAdapter>{children}</NuqsAdapter>
</QueryClientProvider>
)
}Usage
searchMethod receives a zero-based page index and must resolve to a PaginatedApiResponse.
'use client'
import { PaginatedDataTable } from '@/components/custom/paginated-data-table'
import { buildPaginationQuery, type SearchMethodParams } from '@/lib/kiota-pagination'
async function searchEmployees(params: SearchMethodParams) {
const query = buildPaginationQuery(params)
return fetch(`/api/employees?${query}`).then((response) => response.json())
}
export function EmployeeTable() {
return (
<PaginatedDataTable
columns={columns}
searchKeys={['employeeNo', 'fullName']}
searchMethod={searchEmployees}
tableKey="employees"
/>
)
}Response shape
interface PaginatedApiResponse<T> {
status: number
message: string
data: {
content: T[]
page: number
size: number
totalElements: number
totalPages: number
last: boolean
} | null
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
columns | ColumnDef<TData, TValue>[] | — | Required. |
searchKeys | string[] | — | Required. Used only to build the placeholder text. |
searchMethod | (params: SearchMethodParams) => Promise<unknown> | — | Required. Fetches one page. |
tableKey | string | 'paginated-datatable' | Query-cache key and id prefix. Unique per table. |
queryParams | { search?, page?, size? } | search / page / size | Rename the URL parameters. |
searchPlaceholder | string | derived | Overrides the generated placeholder. |
sortField / sortDesc | string / boolean | — | Initial client-side sort of the current page. |
selectedItems / setSelectedItems | string[] / setter | — | Passing these turns on the selection column. |
bodyClass | string | — | Applied to the scroll container; override the 70vh maximum. |
loadingState / errorState | ReactNode | — | Replace the default skeleton and error panel. |
Behaviour notes
Two tables on one route need two queryParams sets, or they will fight over ?page. tableKey alone only separates the caches.
Page numbers are one-based in the URL and zero-based on the wire. People read ?page=1 as the first page; most back ends read 0. The component converts at the boundary.
The previous page stays on screen while the next loads, via keepPreviousData, so paging does not flash an empty table between requests. The thin bar at the top marks the fetch.
Sorting applies to the current page only. The server decides the order across pages; the header sort reorders the rows already fetched. Sort across the whole set in searchMethod instead.
Selection is keyed on uuid or id and persists across pages and searches — it lives in your state, not the table's, so a bulk action can gather rows from several pages.
A hand-edited ?size=99999 is clamped to 500 and written back to the URL, so what is fetched and what the URL claims cannot disagree.