Let the Server Do the Work: Server-Side Pagination with Quasar's QTable
Quasar's QTable can hand paging, sorting, and filtering to your backend via the pagination prop and @request event. Here's the wiring, the rowsNumber gotcha, and the trade-offs.
07 Dec 2025, 22:29 UTC

Your QTable was fine with 200 rows. Then the orders table hit 40,000 records, the browser tab started eating memory, and the initial page load stalled while the API serialized everything. The fix isn't a bigger machine — it's telling QTable to stop pretending it owns the data and let the server page, sort, and filter instead.
Quasar's QTable has a supported server-side mode built around two things: the pagination prop and the @request event. Once you wire them up, every page change, column sort, and filter keystroke becomes a single, well-defined request to your backend. This post shows the wiring (Quasar v2 / Vue 3), the one field everyone gets wrong, and the trade-offs you accept when you take this path.
How server-side mode actually works
In the default mode, you hand QTable the full rows array and it handles paging and sorting in the browser. In server-side mode, you hand it only the current page of rows, plus a pagination object describing where you are in the full dataset:
pageandrowsPerPage— which slice to showsortByanddescending— the active sortrowsNumber— the total rows on the server, not the count of rows you passed
Whenever the user clicks a page, changes rows-per-page, sorts a column, or types in the filter, QTable emits @request with a payload containing pagination and filter. Your handler fetches the matching slice from the API, then updates rows and the pagination object. Because the prop supports v-model:pagination, the same object drives both the table's controls and your request state — no parallel bookkeeping.
A worked example
Here's a minimal but complete pattern for an orders table backed by a typical GET /api/orders?page=&perPage=&sortBy=&desc=&filter= endpoint:
<q-table
title="Orders"
:rows="rows"
:columns="columns"
row-key="id"
v-model:pagination="pagination"
:loading="loading"
:filter="filter"
@request="onRequest"
>
<template v-slot:top-right>
<q-input dense debounce="400" v-model="filter" placeholder="Search" />
</template>
</q-table>const rows = ref([])
const loading = ref(false)
const filter = ref('')
const pagination = ref({
page: 1,
rowsPerPage: 15,
sortBy: 'created_at',
descending: true,
rowsNumber: 0
})
async function onRequest (props) {
const { page, rowsPerPage, sortBy, descending } = props.pagination
loading.value = true
try {
const res = await api.get('/orders', { params: {
page, perPage: rowsPerPage, sortBy,
desc: descending, filter: props.filter
}})
rows.value = res.data.items
pagination.value.page = page
pagination.value.rowsPerPage = rowsPerPage
pagination.value.sortBy = sortBy
pagination.value.descending = descending
pagination.value.rowsNumber = res.data.total // the critical line
} finally {
loading.value = false
}
}
onMounted(() => onRequest({ pagination: pagination.value, filter: filter.value }))Three details matter here. First, rowsNumber must be the server's total count — if you omit it or report the page size, the page-count display and next/previous buttons break. Second, the loading prop isn't just cosmetic: it shows QTable's built-in spinner and discourages overlapping requests from rapid clicking. Third, the debounce="400" on the filter input prevents a request storm while someone types.
What you give up (and what to watch for)
The biggest mental shift: QTable no longer sorts or filters anything. It renders exactly what you give it. If a column has sortable: true but your API ignores sortBy, clicking the header will fire requests that return identical data — a confusing bug that looks like the table is broken. Every sortable column needs backend support, and the filter needs a real query (e.g., SQL LIKE or a search index), not a client-side nicety.
Second, offset-based pagination (LIMIT 15 OFFSET 30) can show duplicates or skip rows when records are inserted or deleted between page requests. For volatile datasets — live order feeds, activity logs — consider cursor-based pagination (WHERE id < :cursor) and map the cursor into your pagination state instead of page numbers.
The upside is that server-side mode composes cleanly with QTable's other features: row selection, expandable rows, and custom body slots all keep working, since they operate on the rows currently rendered.
Verify it before you ship
Point the table at your real API (or a mock with an artificial 500 ms delay) and check four things in the browser's network tab: changing pages fires exactly one request with the expected page/perPage; clicking a sortable header sends sortBy and desc; typing in the filter fires one debounced request; and the page-count footer matches the true server total. If the next button is disabled too early, your rowsNumber is wrong — that's almost always the culprit.
The actionable takeaway: the moment a table's dataset outgrows "load it all," switch to @request plus v-model:pagination, implement sort and filter parameters on the backend, and treat rowsNumber as a first-class part of your API response. The table becomes a dumb, fast viewport — which is exactly what it should be.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.