DEV Community

Kamran Khan
Kamran Khan

Posted on

My users table reads one pagination contract: paginate(15), withQueryString, and a React pager that trusts meta

The first admin panel I built fetched all users, dumped them into state, and sliced pages client-side. It worked until a client hit 2,000 users and the list page took eleven seconds to load.

Server-side pagination isn't hard. The hard part is agreeing on the contract between Laravel and React so neither side improvises.

On the backend, the users index is four lines of intent:

$users = User::query()
    ->latest()
    ->paginate(15)
    ->withQueryString();

return UserResource::collection($users);
Enter fullscreen mode Exit fullscreen mode

paginate(15) does the heavy lifting — limit, offset, total count. withQueryString() appends the current query string to the links Laravel generates, so any future filters I add (search, role) survive a page change. And UserResource::collection() wraps the whole thing in the standard envelope:

{
  "data": [ { "id": 1, "name": "...", "email": "...", "role": "admin", ... } ],
  "links": { "first": "...", "last": "...", "prev": null, "next": "..." },
  "meta": { "current_page": 1, "last_page": 8, "per_page": 15, "total": 112 }
}
Enter fullscreen mode Exit fullscreen mode

The frontend ignores links entirely. My pager reads meta and nothing else:

const fetchUsers = useCallback(async (page = 1) => {
  const res = await client.get('/users', { params: { page } });
  setUsers(res.data.data ?? []);
  setMeta(res.data.meta ?? { current_page: page, last_page: page });
}, []);
Enter fullscreen mode Exit fullscreen mode

Two fallbacks in there worth noticing. res.data.data ?? [] means a broken response shape renders an empty table, not a crash. res.data.meta ?? { current_page: page, last_page: page } means the pager degrades to a single page if the backend ever drops the envelope — the UI stays usable.

The pager itself is dumb on purpose:

{!loading && meta.last_page > 1 && (
  <>
    <button onClick={() => fetchUsers(meta.current_page - 1)} disabled={meta.current_page <= 1}>Prev</button>
    <span>Page {meta.current_page} of {meta.last_page}</span>
    <button onClick={() => fetchUsers(meta.current_page + 1)} disabled={meta.current_page >= meta.last_page}>Next</button>
  </>
)}
Enter fullscreen mode Exit fullscreen mode

No numbered page links, no ellipsis logic. Fifteen rows per page, Prev/Next, done. And after a create or delete, the list refetches meta.current_page — so if you delete the last row on page 8, the table lands back on page 7 instead of showing an empty page that looks like a bug.

One habit that made this durable: the React side never computes total / per_page itself. last_page comes from the backend's meta, full stop. The moment the frontend starts re-deriving pagination math, the two sides drift and someone ends up on a page that doesn't exist.

This contract is one of several I keep consistent across my work — the same envelope powers the dashboard's recent-users list, and the same meta-reading pattern is in the users table component I copy into every admin panel.

The full working example lives in my Laravel + React SaaS Starter Kit — Sanctum auth, role-based access, the admin user loop with this exact pagination contract: https://kamranofficial.gumroad.com/l/mhekoig — $19 one-time.

Top comments (0)