ShadTable
DiscordGitHub

ShadTable

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

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

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

src/components/ssr/data.ts
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.pageSize
return {
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.

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

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

src/components/ssr/pagination-example/data-table.tsx
onPaginationChange: (updater) => {
const next =
typeof updater === 'function'
? updater({ pageIndex: page, pageSize })
: updater
navigate({
search: (prev) => ({
...prev,
page: next.pageIndex,
pageSize: next.pageSize,
}),
})
},