Search docs

Jump to any table example

Discord
ShadTable

A collection of composable table components built on shadcn/ui and TanStack Table.

SSR Sort + Filter + Pagination

Sorting, filtering, and pagination resolved together on the server, from the same URL, in a single request — the way real dashboards actually work, instead of three isolated demos that don't have to interact with each other.

npx shadcn add https://shad-table.dev/r/server-combined-table.json
Name
Email
Role
Status
Ava Thompsonava.t@example.comAdminActive
Liam Chenliam.chen@example.comEditorActive
Sofia Patelsofia.p@example.comViewerInactive
Noah Garcianoah.g@example.comEditorActive
Mia Johnsonmia.j@example.comAdminPending
Ethan Kimethan.kim@example.comViewerActive
Isabella Rossiisabella.r@example.comEditorActive
Lucas Martinlucas.m@example.comAdminInactive
Amelia Novakamelia.n@example.comViewerActive
Mason Leemason.lee@example.comEditorPending

How it works

1.One server function resolves all three, in order

getUsersPageCombined filters first, sorts the filtered set, then paginates the sorted set — in that order, every time. Sorting a filtered-down set (not the full table) and computing pageCount after both is what makes the three features honest about interacting with each other instead of pretending to be independent.

src/components/ssr/data.ts
const filtered = Users.filter((u) => {
if (data.role && u.role !== data.role) return false
if (data.status && u.status !== data.status) return false
return true
})
const sorted = data.sortBy
? [...filtered].sort((a, b) => { /* ... */ })
: filtered
const start = data.page * data.pageSize
return {
rows: sorted.slice(start, start + data.pageSize),
pageCount: Math.ceil(sorted.length / data.pageSize),
}

2.The URL holds all five params — nothing lives in local state

validateSearch parses page, pageSize, role, status, sortBy, and sortDir straight from the URL, and loaderDeps/loader re-run getUsersPageCombined whenever any of them change. There's no separate client-side sorting or filtering state to keep in sync — the URL is the single source of truth for the whole table.

src/routes/server-combined-table.ts
validateSearch: (search) => ({
page: Number(search.page ?? 0),
pageSize: Number(search.pageSize ?? 10),
role: (search.role as string) ?? '',
status: (search.status as string) ?? '',
sortBy: (search.sortBy as string) ?? '',
sortDir: (search.sortDir as string) === 'desc' ? 'desc' : 'asc',
}),
loaderDeps: ({ search }) => search,
loader: ({ deps }) => getUsersPageCombined({ data: deps }),

3.manualPagination, manualSorting, and manualFiltering are all true

Every getXRowModel that would slice, sort, or filter client-side is left out entirely — the table only ever renders the rows the server already resolved for this exact URL. Setting all three manual flags is what stops TanStack Table from silently re-sorting or re-filtering an already-correct server response.

src/components/ssr/combined-example/data-table.tsx
const table = useReactTable({
data: rows,
columns,
pageCount,
manualPagination: true,
manualSorting: true,
manualFiltering: true,
getCoreRowModel: getCoreRowModel(),
// no getSortedRowModel, no getFilteredRowModel, no getPaginationRowModel
})

4.Sorting state is derived from the URL, not useState

sorting is computed fresh from sortBy/sortDir on every render instead of living in its own useState — that's what keeps it consistent with filtering and pagination, which already have to be URL-driven for SSR to work. onSortingChange still uses TanStack's own asc → desc → none cycling logic; it just translates the result into a navigate() call instead of a setState call.

src/components/ssr/combined-example/data-table.tsx
const sorting: SortingState = sortBy
? [{ id: sortBy, desc: sortDir === 'desc' }]
: []
onSortingChange: (updater) => {
const next = typeof updater === 'function' ? updater(sorting) : updater
const nextSort = next[0]
navigate({
search: (prev) => ({
...prev,
sortBy: nextSort?.id ?? '',
sortDir: nextSort?.desc ? 'desc' : 'asc',
page: 0,
}),
})
}

5.Every filter and sort change resets page to 0

Changing the role filter, the status filter, or the sort column all navigate with page: 0 alongside whatever else changed — the result set size or order shifted, so staying on page 4 of a now-2-page result set would just show an empty table. Only page-to-page navigation itself leaves page alone.

src/components/ssr/combined-example/data-table.tsx
navigate({
search: (prev) => ({
...prev,
role: val === 'all' ? '' : val,
page: 0, // reset to page 0 — the result set size changed
}),
})