Server-Side Pagination with v-data-table-server: A Minimal Architecture
An architecture note on Vuetify 3's v-data-table-server: the minimal bindings and fetch composable, the trust boundary around page and sort parameters, race-condition fixes, and when to choose a different design.
03 Nov 2025, 11:36 UTC

Your table has 200,000 rows and shipping them all to the browser is not an option. Vuetify 3's v-data-table-server exists for exactly this case: you hand it only the current page's rows plus a total count, and it renders the pagination controls for you. The useful takeaway: the component is the easy part — the real design work is the trust boundary around page, sort, and page-size parameters, and the race conditions that appear the moment users click faster than your API responds.
This note assumes Vuetify 3. Prop and event names changed between Vuetify 2 (a single v-data-table with server-items-length) and Vuetify 3 (a dedicated v-data-table-server with items-length), and minor releases have adjusted details. Confirm names against the documentation for your installed version before writing any of this.
Requirements that justify server-side pagination
Reach for v-data-table-server when any of these hold:
- The dataset is too large to serialize and ship wholesale.
- Rows are filtered per user, so the client can never hold the authoritative set anyway.
- The query behind the table is expensive, and you want it scoped to one page at a time.
If the filtered dataset is reliably a few hundred rows, plain client-side v-data-table (or v-data-table-virtual for large-but-shippable lists) is simpler — it removes one network request per interaction. Server pagination buys you scalability at the cost of latency and complexity on every click.
The smallest suitable design
For a single table you do not need a global store. Three bindings and one event handler are enough:
:items— the current page's rows.:items-length— the total row count after filters, which the component uses to render page controls.:loading— drives the built-in progress indicator.@update:options— fires on page, sort, or items-per-page changes with a payload shaped like{ page, itemsPerPage, sortBy }.
A small composable wrapping fetch with an AbortController covers the whole client side:
// useServerTable.ts — run in a Vuetify 3 + Vue 3 app
import { ref } from 'vue'
export function useServerTable(fetchPage) {
const items = ref([])
const totalItems = ref(0)
const loading = ref(false)
let controller = null
async function loadItems({ page, itemsPerPage, sortBy }) {
controller?.abort() // cancel any superseded request
controller = new AbortController()
loading.value = true
try {
const res = await fetchPage(
{ page, itemsPerPage, sortBy },
controller.signal
)
items.value = res.items
totalItems.value = res.total // filtered total, not table size
} catch (e) {
if (e.name !== 'AbortError') throw e
} finally {
loading.value = false
}
}
return { items, totalItems, loading, loadItems }
}In the template:
<v-data-table-server
:items="items"
:items-length="totalItems"
:loading="loading"
:headers="headers"
@update:options="loadItems"
/>Expected behavior to check: exactly one network request per user interaction, with the previous in-flight request cancelled.
Trust and data boundaries
Everything in update:options originates in the browser. A user can edit it in devtools regardless of what your UI constrains. The server must therefore:
- Clamp
itemsPerPageto an allowed set (e.g. 10/25/50/100). Do not rely on the dropdown to enforce this. - Validate
page >= 1and clamp it against the computed last page. - Whitelist sort keys.
sortByis an array of{ key, order }objects keyed by your header keys. Map them through a server-side list of sortable, indexed columns — never interpolate client-supplied keys into a SQLORDER BYclause. That is a textbook injection path. - Authorize row visibility per user inside the query, not in the client.
- Treat every filter input as untrusted and parameterize it.
A typical mapping on the server (Node/Express-style pseudocode):
// Server-side. Requires whatever auth middleware establishes req.user.
const SORTABLE = { name: 'u.name', createdAt: 'u.created_at' }
const orderClauses = (req.query.sortBy ?? [])
.filter(s => SORTABLE[s.key] && ['asc', 'desc'].includes(s.order))
.map(s => `${SORTABLE[s.key]} ${s.order.toUpperCase()}`)
// Unknown keys are silently dropped — never passed through.Failure modes worth designing against
Out-of-order responses. A user clicks page 4 then page 5 quickly; page 4's slower response arrives last and overwrites the fresher data. The AbortController above handles this; an alternative is a monotonically increasing request token, discarding any response whose token is not the latest.
Total-count drift. If rows are inserted or deleted between requests, the current page can end up beyond the last page and render empty. Clamp the page server-side and, when you clamp, tell the client so it can re-sync — or simply return the clamped page's rows and let the next interaction correct the UI.
itemsPerPage changes. Switching from 10 to 50 rows per page while on page 8 can point past the end of the data. Reset to page 1 (or re-clamp) whenever page size changes.
Wrong total. items-length must be the filtered count. Passing the unfiltered table size makes the pager claim pages that return no rows.
Sort semantics. Multi-sort ordering and null-value handling vary across versions and backends. Test actual sort clicks against your API contract rather than assuming a payload shape.
Operational checks before shipping
- Point the table at a local mock API and confirm
update:optionsfires once per interaction with the expectedpage,itemsPerPage, andsortBy. - In the browser devtools network tab, verify one request per page/sort/page-size change, correct query parameters, and cancellation of superseded requests.
- Cross-check
items-lengthagainst a directCOUNT(*)with the same filters. - Exercise the failure paths: an API error mid-paging, an empty result set, and navigating to a page that no longer exists after a deletion.
- Confirm the loading indicator appears and clears on both success and error.
Conditions that would change this design
- Cross-page selection or bulk export: selection state can no longer live in the current page's rows; it must move server-side or be keyed by stable row IDs across pages.
- Deep-linkable pages: the component does not persist state. Sync
pageandsortByto the URL query yourself if users should share links to a specific view. - Aggressive latency targets: offset pagination re-scans on every page. Cursor-based pagination or client-side caching of visited pages may be worth the added complexity.
- Shrinking data: if filters reliably reduce results to a few hundred rows, downgrade to client-side pagination and delete the fetch layer.
The design holds up because it is small: one composable, one request contract, one server-side whitelist. Everything else — stores, caches, cursors — is an answer to a requirement you may not have yet.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.