SSR Filter
Filtering by column, resolved on the server the same way SSR Pagination resolves pages.
npx shadcn add https://shadcn-table-library.vercel.app/r/server-filter-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.URL is the source of truth
validateSearch parses page, pageSize, role and status straight off the query string, with defaults for each. Nothing about the current filter lives in React state — bookmarking or refreshing the URL reproduces the exact same view.
validateSearch: (search) => ({page: Number(search.page ?? 0),pageSize: Number(search.pageSize ?? 10),role: (search.role as string) ?? '',status: (search.status as string) ?? '',}),
2.Server function filters before slicing
getUserPageWithFilter simulates a database query — it filters by role/status first, then slices out just the requested page and reports the total pageCount for the filtered set. It only ever runs on the server (createServerFn), with a simulated 400ms delay so the round-trip is visible.
export const getUserPageWithFilter = createServerFn({ method: 'GET' }).validator((input: { page: number; pageSize: number; role?: string; status?: string }) => input).handler(async ({ data }) => {const filtered = Users.filter((u) => {if (data.role && u.role !== data.role) return falseif (data.status && u.status !== data.status) return falsereturn true})const start = data.page * data.pageSizereturn {rows: filtered.slice(start, start + data.pageSize),pageCount: Math.ceil(filtered.length / data.pageSize),}})
3.Route loader resolves before the page renders
loaderDeps forwards the parsed search params, and the route's loader calls getUserPageWithFilter({ data: deps }) before the page renders — so even a hard refresh with a filter already in the URL serves the correctly filtered page, with no client-side fetch.
loaderDeps: ({ search }) => search,loader: ({ deps }) => getUserPageWithFilter({ data: deps }),
4.The table only ever renders one page
The table is only ever handed the current page's already-filtered rows via Route.useLoaderData(), with manualPagination: true so it doesn't re-slice them. There's no getFilteredRowModel or columnFilters state at all — filtering never happens client-side.
const { page, pageSize, role } = Route.useSearch()const { rows, pageCount } = Route.useLoaderData()const table = useReactTable({data: rows,columns,pageCount,manualPagination: true,// ...})
5.Changing a filter navigates, not setState
The Role Select's onValueChange calls navigate to update the URL's role (resetting page back to 0, since the filtered result set size changed). That re-runs the loader and fetches the new page — watch the table dim briefly, that's the real network round-trip.
<Selectvalue={role || 'all'}onValueChange={(val) =>navigate({search: (prev) => ({...prev,role: val === 'all' ? '' : val,page: 0, // reset — the filtered result set size changed}),})}>