Server-Side Pagination with Vuetify's v-data-table: A Controlled-Component Pattern That Scales
Large datasets freeze client-side tables. Here's the controlled-component pattern for Vuetify 3's v-data-table-server: mapping options to query params, canceling stale requests, and testing the edge cases.
13 Aug 2025, 23:42 UTC

Your table works fine in development with 200 seed rows. Then production hits 400,000 records, the browser tries to render them all, and the tab freezes. The fix is not a faster frontend — it is stopping the frontend from owning the data at all.
Vuetify's v-data-table supports this directly, but only if you treat it as a controlled component: your code supplies the rows, the total count, and the loading flag, and the table merely reports what the user asked for. This post walks through that pattern, with a working example and the failure modes worth testing.
Version note: the examples below assume Vuetify 3 (with Vue 3 and the Composition API). Vuetify 2 uses different props and events (options, server-items-length, @update:options semantics differ), so check the props against your installed major version before copying anything.
When server-side mode is actually worth it
Client-side pagination is genuinely simpler: hand the table an array and it slices, sorts, and searches for free. Keep it when your dataset is small (a few thousand rows at most), mostly static, and sorted in obvious ways.
Switch to server-driven mode when any of these hold:
- Row counts are large or unbounded, so shipping everything to the browser is not an option.
- Sorting or filtering involves backend logic — full-text search, joins, permissions-based filtering.
- The backend already owns the query semantics and duplicating them in JavaScript would create two sources of truth.
The cost is real: you now own empty states, error handling, stale-page behavior, and total-count accuracy. That trade-off is the rest of this post.
The request contract: options in, rows and total out
In server mode, the table emits its current state — page, items per page, sort keys — and expects you to turn that into a backend request. The two things you must return together are the page of rows and the total number of matching rows (after filters, before pagination). The total drives the pagination controls; if it is wrong or stale relative to the rows, users see blank pages or phantom page numbers.
A typical mapping looks like this:
page→?page=2itemsPerPage→?per_page=25sortBy(an array of{ key, order }) →?sort=created_at&order=desc
A working example (Vuetify 3)
Run this in a Vue 3 + Vuetify 3 single-file component. No special permissions needed; it assumes an endpoint /api/users that accepts the query parameters above and returns { items: [...], total: number }.
<template>
<v-data-table-server
v-model:items-per-page="itemsPerPage"
:headers="headers"
:items="users"
:items-length="totalUsers"
:loading="loading"
item-value="id"
@update:options="loadUsers"
>
<template #item.status="{ item }">
<v-chip :color="item.active ? 'green' : 'grey'" size="small">
{{ item.active ? 'Active' : 'Disabled' }}
</v-chip>
</template>
</v-data-table-server>
</template>
<script setup>
import { ref } from 'vue';
const headers = [
{ title: 'Name', key: 'name', sortable: true },
{ title: 'Email', key: 'email', sortable: false },
{ title: 'Created', key: 'created_at', sortable: true },
{ title: 'Status', key: 'status', sortable: false },
];
const users = ref([]);
const totalUsers = ref(0);
const itemsPerPage = ref(25);
const loading = ref(false);
let inFlight = null; // AbortController for the current request
async function loadUsers({ page, itemsPerPage: perPage, sortBy }) {
// Cancel the previous request so a slow earlier response
// cannot overwrite a newer page.
inFlight?.abort();
inFlight = new AbortController();
loading.value = true;
try {
const params = new URLSearchParams({ page, per_page: perPage });
const sort = sortBy?.[0];
if (sort) {
params.set('sort', sort.key);
params.set('order', sort.order); // 'asc' | 'desc'
}
const res = await fetch(`/api/users?${params}`, { signal: inFlight.signal });
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
const data = await res.json();
users.value = data.items;
totalUsers.value = data.total; // update rows and total together
} catch (err) {
if (err.name !== 'AbortError') {
// Surface the error in your UI of choice; keep the old rows visible.
console.error(err);
}
} finally {
loading.value = false;
}
}
</script>Three details do the heavy lifting:
@update:optionsis the single entry point. Page changes, page-size changes, and sort clicks all funnel through one handler, so there is one place to enforce consistency.- Rows and total update in the same tick. Assigning
usersandtotalUserstogether prevents the table from briefly rendering a page count that does not match the data. - Slots stay narrow. The
item.statusslot renders a chip; pagination, keyboard navigation, and accessibility remain the table's job. Resist overridingbodyoritemwholesale unless you are prepared to reimplement that behavior.
Race conditions and other ways this breaks
The most common bug in server-mode tables is out-of-order responses. A user clicks page 4, then quickly page 5; the page-4 request is slow and resolves last, so the table shows page 4's rows while the footer says page 5. The AbortController above is the simplest fix — cancel the previous request whenever a new one starts. A debounce (200–300 ms) on top of that reduces backend load when users hammer the pager, but cancellation is the correctness mechanism, not debounce.
Other failure modes worth a deliberate test:
- Sorting while deep in pagination. If the user is on page 12 and changes the sort, page 12 of the new ordering may not exist. Decide explicitly: most apps reset to page 1 on sort or filter changes.
- Shrinking totals. If a filter drops the total below what the current page implies, the table can render an empty grid with a non-empty footer. Clamp the page or refetch page 1 when the total shrinks.
- Empty results and errors. Server mode will not invent a friendly empty state or retry logic for you — both are application code now.
The trade-off, stated plainly
Server-side mode moves complexity from the browser's memory problem to your state-management discipline. You gain a table that stays responsive at any dataset size; you pay for it with request lifecycle handling, count accuracy, and edge cases the component used to absorb. For small internal tools, that payment is not worth it — stay client-side. For anything user-facing over a growing table, it usually is.
How to verify it actually works
Do not trust a happy-path click-through. Throttle your network (browser dev tools' "Slow 3G" preset works) and check: the loading indicator appears on every page change; rapid page clicks never show stale rows; the backend receives the expected page, per_page, sort, and order values; and the boundary cases — first page, last page, an empty result set, and a total smaller than the current page — all render sanely. Finally, confirm the props and events in this post against the documentation for your installed Vuetify major version, since the data-table API changed meaningfully between Vuetify 2 and 3.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.