Vuetify v-data-table Server-Side Pagination: Architecture Note for Vue 3 SPAs
Architecture note for implementing server-side pagination, sorting, and filtering with Vuetify 3's v-data-table in a Vue 3 SPA. Covers minimal client design, trust boundaries, operational checks, failure modes, and design change triggers.
05 Dec 2025, 15:22 UTC

Problem and Takeaway
Large datasets in a Vue 3 single-page application need tabular display with user-driven pagination, sorting, and filtering without shipping all rows to the browser. The takeaway: keep the table in server-items mode, limit client state to page, sort, page size, and filter values, and push all data authority to the API. This avoids client-side caching complexity, keeps the trust boundary clean, and lets the backend enforce authorization and pagination limits.
Requirements
- Consistent tabular UI for datasets that exceed practical client memory (typically >10k rows).
- User controls for page, sort column/direction, page size, and free-text or column filters.
- Accessibility compliance (ARIA table semantics, keyboard navigation, live region for loading).
- Visual consistency with Vuetify theme, density, and color-scheme settings.
Smallest Suitable Design
Use v-data-table (Vuetify 3) with :server-items-length bound to the total count from the API and :items set to the current page array. Do not enable client-side pagination, sorting, or filtering props. The component emits update:options (or update:page, update:sortBy, update:itemsPerPage) which a composable translates into query parameters.
Composable Responsibilities
- Hold reactive state:
page,itemsPerPage,sortBy(array of{ key, order }),filter(string or object). - Build a query string:
?page=2&size=25&sort=createdAt:desc&filter=status:active. - Call the API endpoint, handle abort on rapid changes, toggle
loadinganderrorrefs that the table consumes via:loadingand an error banner slot. - Return
{ items, total, loading, error, refresh }to the component.
No local data cache, no optimistic updates, no client-side sort/filter fallbacks. The composable is ~80 lines and testable in isolation with Vitest and MSW.
Trust and Data Boundaries
| Layer | Responsibility | Trust Level |
|---|---|---|
Vuetify v-data-table | Presentation only: renders rows, shows loading overlay, emits user intent | Untrusted |
| Composable | Parameter validation, request cancellation, mapping API shape to table props | Client-trusted |
| API (REST or GraphQL) | Row shape, total count, authorization, pagination limits, sort column allow-list, index hints | Trust boundary |
Client inputs (sortBy keys, filter values, page size) must be validated and bounded server-side. Example: reject sort=passwordHash:asc, clamp size to 100, sanitize filter strings against injection. The API returns { data: Row[], total: number, sort: { key, order } } — the composable asserts this shape before passing to the table.
Operational Checks
- API latency & error rate for the table endpoint (target p95 < 300 ms, error rate < 0.5%).
- Client render time for visible rows (Vue DevTools performance timeline; aim < 50 ms for 25 rows).
- Loading state correctness: overlay appears on request, disappears on response or error, no flicker on rapid clicks.
- Theme CSS variable application: verify
--v-theme-primary, density classes (.v-table--density-compact), and dark/light mode propagate to table cells without FOUC.
Failure Modes
- Schema mismatch: API adds/removes a column or changes a type (e.g.,
createdAtfrom ISO string to epoch ms). The table throws render errors or shows empty cells. Mitigation: versioned API contract, runtime schema validation (Zod) in the composable. - Stale loading indicator: network latency > 2 s leaves spinner stuck if request abort logic is missing. Mitigation:
AbortControllertied to composable lifecycle; reset loading on catch. - Backend sort on non-indexed column: user sorts by
description(text, no index), query scans full table, latency spikes. Mitigation: API allow-list of sortable columns; return 400 for disallowed keys. - Race conditions from rapid pagination: user clicks page 3, then page 2 before first response returns; out-of-order responses show page 3 data with page 2 selected. Mitigation: composable tracks
requestIdand discards responses whereid !== currentRequestId.
Concrete Example: Composable Skeleton
// composables/useServerTable.ts
import { ref, watchEffect } from 'vue'
import type { TableOptions } from 'vuetify/components'
export function useServerTable(fetchFn: (params: URLSearchParams) => Promise<{ data: any[]; total: number }>) {
const page = ref(1)
const itemsPerPage = ref(25)
const sortBy = ref<{ key: string; order: 'asc' | 'desc' }[]>([])
const filter = ref('')
const loading = ref(false)
const error = ref<Error | null>(null)
const items = ref<any[]>([])
const total = ref(0)
let requestId = 0
const buildParams = () => {
const p = new URLSearchParams()
p.set('page', String(page.value))
p.set('size', String(itemsPerPage.value))
if (sortBy.value.length) p.set('sort', sortBy.value.map(s => `${s.key}:${s.order}`).join(','))
if (filter.value) p.set('filter', filter.value)
return p
}
const load = async () => {
loading.value = true
error.value = null
const id = ++requestId
try {
const params = buildParams()
const res = await fetchFn(params)
if (id !== requestId) return // stale response
items.value = res.data
total.value = res.total
} catch (e) {
if (id !== requestId) return
error.value = e as Error
items.value = []
total.value = 0
} finally {
if (id === requestId) loading.value = false
}
}
watchEffect(() => { load() })
return { items, total, loading, error, page, itemsPerPage, sortBy, filter, refresh: load }
}
Component usage:
<v-data-table
:items="items"
:server-items-length="total"
:loading="loading"
:options="{ page: page, itemsPerPage: itemsPerPage, sortBy: sortBy }"
@update:options="opts => { page.value = opts.page; itemsPerPage.value = opts.itemsPerPage; sortBy.value = opts.sortBy }"
>
<template #no-data>No rows</template>
<template #no-results>No matches</template>
</v-data-table>Conditions That Change the Design
- Dataset fits in memory (<5k rows, low churn): switch to client-side
itemswith:server-items-length="0"and drop the composable. - Offline support required: add local-first cache (IndexedDB + sync engine), optimistic UI, and conflict resolution — different architecture entirely.
- Multi-tenant theming with server-driven CSS variables: API returns theme tokens; app applies via
document.documentElement.style.setPropertybefore table mount. - Real-time updates (WebSocket push): replace pull pagination with cursor-based subscription; table becomes a projection of a local ordered store.
Verification Steps
- In a dev build, inspect the table component props (Vue DevTools) to confirm
server-items-lengthis numeric andloadingtoggles on each request. - Compare the API response shape (
dataarray,totalinteger, optionalsortecho) against the composable's expectations; log mismatches in development. - Simulate rapid page/sort changes (click page 3, immediately page 2, then sort by name) and verify the network tab shows aborted requests and final UI matches the last user action.
Limitations
- Vuetify 2 vs 3 prop names differ (
server-items-lengthvs:server-items-length,optionsobject shape). This note assumes Vuetify 3.4+ with Vue 3.4+. - Theme CSS variable names (
--v-table-header-background, etc.) are not guaranteed stable across minor versions; test after upgrades. - No built-in request deduplication beyond the
requestIdpattern; high-frequency filter keystrokes need debounce in the composable.
Practical Result Check
Open the Network tab, filter to the table endpoint. Perform a sort, page change, and filter edit. Each action should produce exactly one successful request (or one aborted + one successful). The table should never show a loading overlay without a pending request, and the row count badge should match total from the latest response.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.