Vuetify 3 v-data-table Against a Remote API: Keeping Pagination, Sorting, and Loading Honest
Wire Vuetify 3's v-data-table to a remote API without a lying pagination footer: bind items-length to the server total, drive fetches from v-model:options, and guard against out-of-order responses.
15 Feb 2026, 05:33 UTC

The first symptom is usually a footer that lies. You wire v-data-table to an API, the rows look right, but the pagination control shows 12 pages when the endpoint only has 3 — or clicking page 2 renders the same rows as page 1. The cause is almost never the table. It is that the component and the server disagree about who owns the dataset.
The fix is to treat the table as a view over a server query rather than a container for data. Vuetify 3 supports this directly: bind :items to the current page only, tell the table how many rows exist in total, and let one options object drive every request.
The three bindings that make the table server-driven
Vuetify 3 ships v-data-table-server, a thin wrapper that pre-configures the server-side defaults. The props underneath are the same ones you would set on v-data-table:
items— the rows for the current page, not the whole dataset.items-length— the total row count on the server. This is what the footer uses to compute page count, so it must come from the API response, not fromitems.length.v-model:options— a two-way binding for{ page, itemsPerPage, sortBy }. The table updates it whenever the user changes page, page size, or a sort header, and the component emitsupdate:optionswith the same payload.
Sorting becomes server-side as soon as you stop relying on the default client comparator. sortBy arrives as an array of { key, order } objects; pass it to the API and let the database order the rows. Adding must-sort removes the third, unsorted state so the table always emits a defined direction.
A worked example
Assume a Vue 3 single-file component in a Vuetify 3 project, with an endpoint GET /api/orders that accepts page, perPage, sortKey, and sortOrder and returns { items, total }. The placeholder names are yours to replace.
<template>
<v-data-table-server
v-model:options="options"
:headers="headers"
:items="rows"
:items-length="total"
:loading="loading"
item-key="id"
must-sort
@update:options="load"
>
<template #no-data>
<p>No orders match the current filters.</p>
</template>
</v-data-table-server>
</template>
<script setup>
import { ref } from 'vue'
const rows = ref([])
const total = ref(0)
const loading = ref(false)
const options = ref({
page: 1,
itemsPerPage: 25,
sortBy: [{ key: 'createdAt', order: 'desc' }],
})
const headers = [
{ title: 'Order', key: 'id' },
{ title: 'Customer', key: 'customer' },
{ title: 'Created', key: 'createdAt' },
]
let requestId = 0
async function load(opts) {
const id = ++requestId
loading.value = true
const params = new URLSearchParams({
page: String(opts.page),
perPage: String(opts.itemsPerPage),
sortKey: opts.sortBy?.[0]?.key ?? 'createdAt',
sortOrder: opts.sortBy?.[0]?.order ?? 'desc',
})
try {
const res = await fetch(`/api/orders?${params}`)
if (!res.ok) throw new Error(`Request failed: ${res.status}`)
const body = await res.json()
if (id !== requestId) return // a newer request already started
rows.value = body.items
total.value = body.total
} finally {
if (id === requestId) loading.value = false
}
}
</script>
Two details carry most of the weight. The requestId guard discards stale responses: a fast click through pages 2, 3, and 4 can resolve out of order, and without the guard the table flickers back to page 2's rows. The loading prop disables interaction and shows the built-in progress indicator, which stops users from stacking requests on top of each other.
Filters change the contract
Once the table holds only one page, client-side search and column filters stop working — there is nothing local to filter. Filtering has to become a query parameter, and the response's total has to reflect the filtered count, or the footer will offer pages that the API cannot fill.
The related trap is page position. If a user is on page 7 and then narrows a filter, reset page to 1 before fetching. Otherwise the request asks for page 7 of a three-page result set and the table renders empty. Reassigning the options object is more predictable than mutating nested properties, and it is worth confirming in the network tab that a filter change produces exactly one request rather than two.
Trade-offs worth naming
- Latency replaces memory. Every page change is a round trip. Debounce text filters so a keystroke does not become a request.
- More moving parts. The client-side version needs no endpoint contract. The server version needs a stable
{ items, total }shape and consistent sort key names between headers and the backend. - Accessibility. The default progress indicator is not announced automatically. Pair the loading state with an
aria-liveregion, and make sure the no-data slot conveys the same information visually and to screen readers. - DOM cost.
v-data-tableis heavy. For very wide tables,fixed-headerand hiding unused columns do more for perceived speed than any fetch optimization.
How to check it actually works
- Bind
v-model:optionsand log the emitted value. Confirmpage,itemsPerPage, andsortBymatch what the footer and headers show. - Point
:itemsat a local array containing one page of fake rows and setitems-lengthto a larger number. The footer should show the correct page count, and changing pages should not reorder the loaded rows. - Open the browser's network tab and click quickly between pages and sort headers. You want one in-flight request at a time and no late response overwriting newer rows.
- Return a zero-result response. The no-data slot should render and pagination should be disabled rather than showing page 1 of 0.
If any of those checks fail, the mismatch is almost always items-length drifting from the server's real total, or a sort key that the backend silently ignores. Fix the contract before adding more table features.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.