ShadTable
DiscordGitHub

ShadTable

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

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.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.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.

src/routes/server-filter.ts
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.

src/components/ssr/data.ts
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 false
if (data.status && u.status !== data.status) return false
return true
})
const start = data.page * data.pageSize
return {
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.

src/routes/server-filter.ts
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.

src/components/ssr/filter-example/data-table.tsx
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.

src/components/ssr/filter-example/data-table.tsx
<Select
value={role || 'all'}
onValueChange={(val) =>
navigate({
search: (prev) => ({
...prev,
role: val === 'all' ? '' : val,
page: 0, // reset — the filtered result set size changed
}),
})
}
>