SSR Pagination
Only the current page's rows are ever sent to the browser — changing pages triggers a real server request instead of slicing an in-memory array.
npx shadcn add https://shadcn-table-library.vercel.app/r/server-pagination-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 and pageSize straight off the query string, with defaults for each. Nothing about the current page 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),}),
2.Server function slices the page
getUsersPage (in data.ts) simulates a database query — it slices out just the requested page and reports the total pageCount. It only ever runs on the server (createServerFn), with a simulated 400ms delay so the round-trip is visible.
export const getUsersPage = createServerFn({ method: 'GET' }).validator((input: { page: number; pageSize: number }) => input).handler(async ({ data }) => {await new Promise((resolve) => setTimeout(resolve, 400))const start = data.page * data.pageSizereturn {rows: Users.slice(start, start + data.pageSize),pageCount: Math.ceil(Users.length / data.pageSize),}})
3.Route loader resolves before the page renders
loaderDeps forwards the parsed search params, and the route's loader calls getUsersPage({ data: deps }) before the page renders — so even a hard refresh serves the correct page already rendered, with no client-side fetch.
loaderDeps: ({ search }) => search,loader: ({ deps }) => getUsersPage({ data: deps }),
4.The table only ever renders one page
The table is only ever handed the current page's rows via Route.useLoaderData(), with manualPagination: true telling TanStack Table not to slice the data itself — it just renders whatever page the server sent.
const { page, pageSize } = Route.useSearch()const { rows, pageCount } = Route.useLoaderData()const table = useReactTable({data: rows,columns,pageCount,manualPagination: true,state: {pagination: { pageIndex: page, pageSize },},})
5.Changing pages navigates, not setState
onPaginationChange navigates to update the URL's page/pageSize instead of writing to local state. That re-runs the loader and fetches the next page — watch the table dim briefly, that's the real network round-trip.
onPaginationChange: (updater) => {const next =typeof updater === 'function'? updater({ pageIndex: page, pageSize }): updaternavigate({search: (prev) => ({...prev,page: next.pageIndex,pageSize: next.pageSize,}),})},