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.jsonName | Email | Role | Status |
|---|---|---|---|
| Ava Thompson | ava.t@example.com | Admin | Active |
| Liam Chen | liam.chen@example.com | Editor | Active |
| Sofia Patel | sofia.p@example.com | Viewer | Inactive |
| Noah Garcia | noah.g@example.com | Editor | Active |
| Mia Johnson | mia.j@example.com | Admin | Pending |
| Ethan Kim | ethan.kim@example.com | Viewer | Active |
| Isabella Rossi | isabella.r@example.com | Editor | Active |
| Lucas Martin | lucas.m@example.com | Admin | Inactive |
| Amelia Novak | amelia.n@example.com | Viewer | Active |
| Mason Lee | mason.lee@example.com | Editor | Pending |
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.
const filtered = Users.filter((u) => {if (data.role && u.role !== data.role) return falseif (data.status && u.status !== data.status) return falsereturn true})const sorted = data.sortBy? [...filtered].sort((a, b) => { /* ... */ }): filteredconst start = data.page * data.pageSizereturn {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.
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.
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.
const sorting: SortingState = sortBy? [{ id: sortBy, desc: sortDir === 'desc' }]: []onSortingChange: (updater) => {const next = typeof updater === 'function' ? updater(sorting) : updaterconst 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.
navigate({search: (prev) => ({...prev,role: val === 'all' ? '' : val,page: 0, // reset to page 0 — the result set size changed}),})