Architecture Note: Server‑Side Pagination with Vuetify 3 VDataTable
How to scope Vuetify 3 VDataTable server‑side pagination: requirements, minimal design, trust boundaries, and failure modes for large or sensitive datasets.
09 Sept 2026, 17:11 UTC

When a Vue application renders a table with thousands of rows, the default client‑side approach downloads every record to the browser before slicing a visible page. For large or sensitive datasets, that is both a performance problem and a data exposure problem. Server‑side pagination in Vuetify 3 VDataTable keeps the client holding only the current page and a total count, but it shifts real responsibility to the backend. This note covers the requirements, the smallest viable design, where trust boundaries sit, and the checks that catch common failures.
Requirements
Use server‑side pagination when the dataset exceeds a few hundred rows or contains fields that should not all be sent to the browser at once. The backend must be able to accept a page number, a page size, optional sort keys, and optional filter values, then return a payload containing the visible rows and a separate total count. The client should reflect the current page, page size, sort, and filter in the URL so the view is shareable and survives a reload.
Smallest suitable design
The minimal design binds the table’s total row count to the server response, shows a loading state while a request is in flight, and reacts to user‑driven pagination events by fetching a new page. Filters and sorting are sent as query parameters rather than applied in the browser.
Table configuration
In Vuetify 3, VDataTable exposes an items-length prop for the total count, a loading prop for in‑flight requests, and an update:options event that fires when the user changes page, items‑per‑page, or sorting. A minimal handler follows this pattern:
<template>
<v-data-table
:headers="headers"
:items="items"
:items-length="totalItems"
:loading="loading"
@update:options="loadItems"
/>
</template>
<script setup>
import { ref } from 'vue'
const items = ref([])
const totalItems = ref(0)
const loading = ref(false)
async function loadItems({ page, itemsPerPage, sortBy }) {
loading.value = true
const params = new URLSearchParams({
page: String(page),
itemsPerPage: String(itemsPerPage),
})
if (sortBy.length) {
params.set('sort', sortBy[0].key)
params.set('order', sortBy[0].order)
}
try {
const res = await fetch(`/api/records?${params}`)
const data = await res.json()
items.value = data.items
totalItems.value = data.total
} finally {
loading.value = false
}
}
</script>
Because Vuetify 3 changed the VDataTable API from Vuetify 2, confirm the project uses Vuetify 3.x and inspect the installed package’s type definitions for exact prop and slot names before implementation. Minor releases can adjust internals, so treat the snippet above as a conceptual starting point rather than a guaranteed drop‑in.
Backend contract
The server must return a shape compatible with the items prop and a separate total count. A mismatch causes silent rendering failures: the table may show zero rows even when data exists. The total count must be computed from the same filter and authorization scope as the returned rows, not from an unfiltered table count.
Trust and data boundaries
The browser controls pagination state, but it must not be trusted for authorization or data filtering. A malicious user can manipulate page numbers, page sizes, sort keys, or filter parameters to probe for data existence. Every request must be re‑validated on the server: clamp page and items‑per‑page to sane bounds, verify sort keys against an allow‑list, and enforce row‑level security before returning any rows.
Never embed authorization logic in the client. If the client filters out rows the user should not see, the raw data has already crossed the trust boundary. The server is the only place that can decide which rows are visible.
Operational checks
Version and network verification
Run the following command in your project root to confirm the installed Vuetify version:
npm list vuetify
Expected result: a 3.x version. No special permissions are required. If the output shows 2.x, this note’s prop and event names do not apply.
To verify the backend response shape, run a request against your API from a terminal with network access:
curl -s "http://localhost:8000/api/records?page=1&itemsPerPage=25" | head -c 500
Expected result: JSON containing an items array and a numeric total field. Risk: if the endpoint is unauthenticated, this exposes data; use a development environment only.
Race‑condition and state checks
Rapid pagination clicks can cause an older response to overwrite a newer page. Use an AbortController to cancel stale in‑flight requests:
let controller = null
async function loadItems(options) {
if (controller) {
controller.abort()
}
controller = new AbortController()
const params = new URLSearchParams({
page: String(options.page),
itemsPerPage: String(options.itemsPerPage),
})
if (options.sortBy.length) {
params.set('sort', options.sortBy[0].key)
params.set('order', options.sortBy[0].order)
}
try {
const res = await fetch(`/api/records?${params}`, {
signal: controller.signal,
})
const data = await res.json()
items.value = data.items
totalItems.value = data.total
} catch (err) {
if (err.name !== 'AbortError') {
throw err
}
} finally {
loading.value = false
}
}
In browser dev tools, confirm that changing pages issues a new request containing the expected query parameters and that only one page of data crosses the network. Simulate rapid clicks and verify the UI does not display rows from an aborted or delayed response.
Distinguish three UI states explicitly: loading, empty result, and error. An empty result is not an error, and an error should not silently look like an empty table.
Failure modes
- Stale responses: an older, slower request resolves after a newer one and overwrites the visible rows. Aborting or ignoring out‑of‑order responses prevents this.
- Total‑count mismatch: the backend returns a total larger than the actual filtered set, producing blank pages when the user navigates near the end. The server must compute the total from the same query scope as the rows.
- Client‑side fallback: logic that fetches the full dataset to filter or sort locally defeats the purpose of server‑side pagination and re‑introduces the exposure risk.
- Shape mismatch: a backend payload that does not match the expected
itemsstructure can cause silent rendering failures with no obvious error.
Conditions that would change the design
Reconsider server‑side pagination if the dataset is small and non‑sensitive; client‑side pagination is simpler and avoids backend round‑trips. If records change frequently, add polling or websockets rather than relying on manual refresh. If the interface requires complex faceted search with aggregations, a dedicated search service may be more appropriate than extending the table’s query parameters.
Limitations and practical verification
This note assumes Vuetify 3.x. Exact prop names, slot names, and event payloads should be verified against the installed package’s type definitions before shipping. The examples above are illustrative and have not been executed in a live environment.
A practical final check: navigate to an out‑of‑range page (for example, request page 999) and confirm the UI handles the empty or clamped result gracefully rather than rendering a blank table with a misleading total count. Then reload the page and verify the URL restores the same view.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.