Server-Side Tables in Vuetify 3: Moving Pagination, Sorting, and Filtering to the API
When a Vuetify table grows past a few thousand rows, client-side pagination hurts. v-data-table-server moves sorting, filtering, and paging to your API—if you can manage the trade-offs.
05 Oct 2025, 22:20 UTC

You wire up a Vuetify table with a few thousand rows, and everything feels fine until a user opens the page on a mid-range laptop. Scrolling stutters, sorting lags, and the initial payload balloons to several megabytes. The bottleneck is not Vuetify itself; it is the fact that v-data-table expects every row to live in the browser. The useful takeaway: when your dataset outgrows the client, switch to v-data-table-server and let your API handle pagination, sorting, and filtering.
Why server-side mode changes the contract
In Vuetify 3, v-data-table-server does not fetch or sort data on its own. Instead, it emits an update:options event whenever the user changes page size, sorts a column, or types in a search field. Your code listens for that event, translates it into query parameters, and asks the backend for exactly one page of results.
That shift moves complexity from the component to your API. The backend must accept parameters such as page, items-per-page, sort-by, and search, and it must return a stable total count so the pagination footer is accurate. If the count drifts, the UI will show the wrong number of pages.
If you are migrating from Vuetify 2, note that Vuetify 3 replaces legacy props like server-items-length and must-sort. Because prop names and event payloads can differ between major versions, confirm the exact API against the Vuetify version in your package.json before you commit to a refactor.
A minimal wiring example
The following sketch shows the essential bindings. It assumes a Vue 3 single-file component using <script setup>. The total-count prop is shown as :items-length; if your installed Vuetify release uses a different name for that value, adjust the binding accordingly.
<template>
<v-data-table-server
:headers='headers'
:items='serverItems'
:items-length='totalItems'
:loading='loading'
item-value='id'
@update:options='loadFromServer'
></v-data-table-server>
</template>
<script setup>
import { ref } from 'vue'
const headers = [
{ title: 'Dessert', key: 'name' },
{ title: 'Calories', key: 'calories' },
]
const serverItems = ref([])
const totalItems = ref(0)
const loading = ref(false)
async function loadFromServer({ page, itemsPerPage, sortBy, search }) {
loading.value = true
try {
const params = new URLSearchParams({
page: String(page),
per_page: String(itemsPerPage),
sort: sortBy.map(s => `${s.key}:${s.order}`).join(','),
q: search,
})
const res = await fetch(`/api/desserts?${params}`)
const data = await res.json()
serverItems.value = data.items
totalItems.value = data.total
} finally {
loading.value = false
}
}
</script>Two details matter here. First, the loading prop is manual: you own it. Without it, rapid clicks on pagination controls can fire overlapping requests, and a slow response can overwrite a newer one—a race condition that leaves stale rows on screen. Second, search is not debounced by the component. Every keystroke emits update:options, so throttle the handler or debounce the input (waiting until typing pauses) before it reaches the network layer.
Bookmarkable table state is your responsibility
Client-side tables keep their state in memory, which is convenient but not shareable. With v-data-table-server, the current page and sort order live in your component state, not the URL. If users need to bookmark or share a filtered view, serialize the relevant options into query parameters yourself and restore them when the component mounts.
Trade-offs you are signing up for
The biggest cost is backend surface area. Your API now needs a predictable contract for page, items-per-page, sort-by, and search, plus a total count that does not fluctuate between requests. If the count is approximate or cached aggressively, pagination controls will mislead users.
There is also a version-verification cost. Vuetify's data-table family has changed prop names and event shapes across major releases. Because newer releases may exist beyond common knowledge cutoffs, treat the example above as a pattern to verify against your installed version, not a drop-in guarantee.
How to check your wiring
- Run
npm list vuetifyin your project root to confirm the installed version. No special permissions are required. - Add a temporary
console.loginsideloadFromServerand interact with the table. Confirm theupdate:optionspayload contains the fields you expect—page, itemsPerPage, sortBy, and search. - Compare the pagination footer text against the
totalItemsvalue you return from the API. If they disagree, the total-count binding is wrong for your version. - Remove the logging before merging.
Start with a mock async function that returns a fixed page of data and a hardcoded total. Once the event payload and pagination math line up, swap the mock for your real endpoint. That sequence keeps the client contract stable while the backend catches up.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.