Server-Side Tables in Vuetify 3: Making v-data-table-server Earn Its Keep
v-data-table-server in Vuetify 3 moves pagination and sorting to your API. Here is the options-event contract, a working users-table example, and the pitfalls that break totals, pages, and sort safety.
11 Nov 2025, 10:46 UTC

The moment v-data-table stops scaling
Client-side tables are delightful right up until your API returns 200,000 rows and the browser tab starts swapping. The regular v-data-table wants the entire dataset in memory so it can paginate, sort, and filter locally. Once your dataset outgrows that, the fix is not pagination tricks — it is moving the work to the server. That is exactly what v-data-table-server exists for in Vuetify 3.
The takeaway up front: the component does not fetch anything for you. It emits an event describing what the user asked for — which page, how many rows, sorted by what — and your job is to translate that into an API call and hand back a page of rows plus a total count. Get that contract right and the component handles all the UI; get it wrong and you get phantom pages, stale counts, and sortable SQL injection vectors.
This article assumes Vuetify 3 with Vue 3 and the Composition API. Prop and event names changed between Vuetify 2 and 3, so confirm against the docs for your pinned version before copying anything.
The load contract: options in, rows and total out
Instead of binding :items to a full array, you listen for @update:options. The emitted options object carries the fields your backend cares about:
page— the 1-based page the user wants.itemsPerPage— page size (-1can mean "all", so decide how your API handles that).sortBy— an array of{ key, order }entries whereorderis'asc'or'desc'.searchandgroupBy— present depending on your configuration.
Two props complete the loop. items-length must be the total row count from the server — without it, the footer cannot compute page counts or know when to disable "next". And loading should track your in-flight request so the built-in progress bar and disabled controls give honest feedback.
A worked example: a users table
Here is a minimal but realistic setup. Run this anywhere in a Vue 3 + Vuetify 3 app; the only permission you need is whatever your API requires.
<template>
<v-data-table-server
:headers="headers"
:items="users"
:items-length="totalUsers"
:loading="loading"
:items-per-page="10"
@update:options="loadUsers"
/>
</template>
<script setup>
import { ref } from 'vue'
const headers = [
{ title: 'Name', key: 'name', sortable: true },
{ title: 'Email', key: 'email', sortable: true },
{ title: 'Created', key: 'createdAt', sortable: true },
]
const users = ref([])
const totalUsers = ref(0)
const loading = ref(false)
async function loadUsers({ page, itemsPerPage, sortBy }) {
loading.value = true
try {
const params = new URLSearchParams({
page: String(page),
perPage: String(itemsPerPage),
})
if (sortBy.length) {
params.set('sort', sortBy[0].key)
params.set('order', sortBy[0].order)
}
const res = await fetch(`/api/users?${params}`)
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`)
const { rows, total } = await res.json()
users.value = rows
totalUsers.value = total
} finally {
loading.value = false
}
}
</script>To verify the wiring, open browser devtools on the Network tab and click through pages, change items-per-page, and click a sortable header. Each interaction should produce exactly one request with the parameters you expect, and the footer page count should match total / itemsPerPage. Do not assume it works because the table renders — the failure modes are subtle.
Where implementations go wrong
Unsafe sort keys. Never interpolate sortBy[0].key into a SQL ORDER BY. The client can send anything. Map keys through an allowlist on the backend:
const SORTABLE = { name: 'u.name', email: 'u.email', createdAt: 'u.created_at' }
const column = SORTABLE[req.query.sort] ?? 'u.created_at'
const direction = req.query.order === 'asc' ? 'ASC' : 'DESC'
// ORDER BY ${column} ${direction} — column is now allowlisted, not user inputInconsistent totals. items-length and the page rows must come from the same filter state. If the count query ignores a filter the row query applies, users see empty pages or a "next" button that leads nowhere.
Not resetting the page. When search text or filters change, reset to page 1. Otherwise a user on page 40 who narrows the search lands on an out-of-range empty page with no obvious way back. Debounce search input (300 ms is a reasonable starting point) so typing does not fire a request per keystroke.
Shrinking datasets. If rows are deleted while a user sits on the last page, the current page can become invalid. A pragmatic fix: when a fetch returns zero rows for a page greater than 1 and the total is nonzero, refetch page 1.
The trade-off you are accepting
Compared to client-side v-data-table, you give up instant, free sorting and filtering. Every interaction is now a network round trip, and correctness — stable ordering (add a tiebreaker like an id to your ORDER BY or rows can shuffle between pages), accurate totals, sensible empty states — becomes your API's responsibility. For datasets under a few thousand rows, the client-side table is genuinely simpler and faster for users. Reach for the server variant when row counts make shipping the whole dataset to the browser absurd, not by default.
A checklist before you ship
- Confirm the exact prop and event names against the docs for your installed Vuetify major version.
- Test the edge cases: empty results, a partial last page, and deleting rows until the current page goes out of range.
- Type quickly into search with devtools open and confirm debouncing collapses the requests.
- Send a malicious
sortparameter by hand and confirm the backend rejects or ignores it.
The component's side of the bargain is small — it renders what you give it and tells you what the user wants. Everything interesting happens in how faithfully you answer that question.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.