Quasar QTable gives you sorting, filtering and pagination without a single extra package, but the defaults only get you halfway. This tutorial builds one orders table from an empty file: typed columns, sorting that behaves, a search box, pagination that remembers its state, and a status cell that shows a colored badge. Every code sample runs on Quasar 2.35.
If your table lives inside an app shell with a header and drawer, the Quasar layout system guide covers that part. Here we stay inside the table.
A QTable needs two arrays: rows (your data) and columns (how to show it). Start with the rows. Each one needs a unique key, which QTable reads through row-key.
interface Order {
id: number;
customer: string;
email: string;
total: number;
status: "paid" | "pending" | "refunded";
createdAt: string;
}
const rows: Order[] = [
{ id: 1041, customer: "Ana Lima", email: "ana@example.com", total: 129, status: "paid", createdAt: "2026-10-28" },
{ id: 1042, customer: "Tom Reyes", email: "tom@example.com", total: 48.5, status: "pending", createdAt: "2026-10-29" },
{ id: 1043, customer: "Mei Chen", email: "mei@example.com", total: 310, status: "refunded", createdAt: "2026-10-30" },
// ...
];Now the columns. Quasar ships a QTableColumn type, so the editor checks the field names against your row type:
import type { QTableColumn } from "quasar";
const columns: QTableColumn<Order>[] = [
{ name: "id", label: "Order", field: "id", align: "left", sortable: true },
{ name: "customer", label: "Customer", field: "customer", align: "left", sortable: true },
{ name: "total", label: "Total", field: "total", sortable: true, format: (val: number) => `$${val.toFixed(2)}` },
{ name: "status", label: "Status", field: "status", align: "center", sortable: true },
{ name: "createdAt", label: "Date", field: "createdAt", sortable: true },
];Three things worth knowing about these objects:
name is the column's id. Sorting, slots and visible-columns all refer to it.align defaults to right. That suits numbers, so total leaves it out, but text columns usually want left.format only changes what is displayed. The raw total number is still what gets sorted.The email field has no column, so it isn't shown. It can still be used later.
Put the two arrays into the component:
<template>
<q-table
title="Orders"
:rows="rows"
:columns="columns"
row-key="id"
flat
bordered
/>
</template>That already gives you a header, clickable sort arrows on every sortable column, and pagination at the bottom with a rows-per-page picker. The rest of this tutorial tunes each of those.
Click a sortable header and QTable sorts ascending, then descending, then back to unsorted. That third click confuses most users, who expect it to flip again. The binary-state-sort prop removes the unsorted state:
<q-table
:rows="rows"
:columns="columns"
row-key="id"
binary-state-sort
/>How the default sort compares values is decided by the raw field value:
Date objects are compared as dates;Our createdAt holds ISO strings like 2026-10-28, which sort correctly as text. A date like 28/10/2026 would not. When a column needs its own rule, give it a sort function. It receives the two values and the two rows:
const statusOrder = { pending: 0, paid: 1, refunded: 2 };
{
name: "status",
label: "Status",
field: "status",
sortable: true,
sort: (a: Order["status"], b: Order["status"]) => statusOrder[a] - statusOrder[b],
}Rows whose value is null or undefined are put aside before sort runs. If you need to place them yourself, use rawSort instead, which receives them too.
Filtering is one prop. Bind a string to filter, and QTable keeps only the rows where some visible column contains that text, ignoring case:
<template>
<q-table
title="Orders"
:rows="rows"
:columns="columns"
row-key="id"
:filter="filter"
binary-state-sort
no-results-label="No orders match your search"
>
<template #top-right>
<q-input
v-model="filter"
dense
outlined
debounce="300"
placeholder="Search orders"
>
<template #append>
<q-icon name="search" />
</template>
</q-input>
</template>
</q-table>
</template>
<script setup lang="ts">
import { ref } from "vue";
const filter = ref("");
</script>The top-right slot puts the search box in the table's own header, next to the title. debounce="300" waits until the user stops typing before the filter runs. When the filter changes, QTable jumps back to page 1 by itself.
Two details of the default filter catch people out:
$129 finds the first order.email field has no column, so typing an email finds nothing.To search fields that have no column, or to match in a different way, pass a filter-method. It receives the rows, the filter value, the columns and a helper that reads a cell value, and it returns the rows to keep. Define it in your script rather than inline, which is what the Quasar docs advise for speed:
function filterOrders(rows: readonly Order[], terms: string) {
const needle = terms.toLowerCase();
return rows.filter(
(row) =>
row.customer.toLowerCase().includes(needle) ||
row.email.toLowerCase().includes(needle) ||
String(row.id).includes(needle),
);
}<q-table :filter="filter" :filter-method="filterOrders" />Pagination state is one object: sortBy, descending, page and rowsPerPage. Pass it with v-model:pagination and you can read and set it from your own code:
import type { QTableProps } from "quasar";
const pagination = ref<QTableProps["pagination"]>({
sortBy: "createdAt",
descending: true,
page: 1,
rowsPerPage: 10,
});<q-table
v-model:pagination="pagination"
:rows-per-page-options="[5, 10, 25, 0]"
/>This opens the table sorted newest first, ten rows a page. rows-per-page-options sets the choices in the picker; 0 means "all rows". The default list is [5, 7, 10, 15, 20, 25, 50, 0], which is more choice than most admin pages need.
Because the state is a plain ref, you can keep it in the URL, in a Pinia store, or in localStorage, and the user comes back to the page and sort they left.
If you pass :pagination without v-model, it only sets the starting state. That is fine when nothing else needs to know the current page.
Everything above sorts and filters in the browser, which is fine for a few thousand rows. For more, add rowsNumber (the total row count on the server) to the pagination object. QTable then stops slicing the rows itself and emits @request with the new pagination and filter every time the user changes page, sort or search. You fetch that one page, set rows, and update pagination. Set the loading prop while the request runs.
The last step is the status column. Plain text like refunded is hard to scan. The body-cell-[name] slot replaces one column's cells, so body-cell-status targets the status column:
<template #body-cell-status="props">
<q-td :props="props">
<q-badge
:color="statusColor[props.value as Order['status']]"
:label="props.value"
rounded
/>
</q-td>
</template>const statusColor: Record<Order["status"], string> = {
paid: "positive",
pending: "warning",
refunded: "grey-7",
};Always wrap slot content in <q-td :props="props">. It applies the column's alignment, classes and styles, so the cell lines up with its header. props.value is the cell's value after format, and props.row is the whole row if you need another field.
Here is everything in one file, ready to paste:
<template>
<q-table
title="Orders"
:rows="rows"
:columns="columns"
row-key="id"
v-model:pagination="pagination"
:rows-per-page-options="[5, 10, 25, 0]"
:filter="filter"
binary-state-sort
no-results-label="No orders match your search"
flat
bordered
>
<template #top-right>
<q-input v-model="filter" dense outlined debounce="300" placeholder="Search orders">
<template #append>
<q-icon name="search" />
</template>
</q-input>
</template>
<template #body-cell-status="props">
<q-td :props="props">
<q-badge :color="statusColor[props.value as Order['status']]" :label="props.value" rounded />
</q-td>
</template>
</q-table>
</template>
<script setup lang="ts">
import { ref } from "vue";
import type { QTableColumn, QTableProps } from "quasar";
interface Order {
id: number;
customer: string;
email: string;
total: number;
status: "paid" | "pending" | "refunded";
createdAt: string;
}
const rows: Order[] = [
{ id: 1041, customer: "Ana Lima", email: "ana@example.com", total: 129, status: "paid", createdAt: "2026-10-28" },
{ id: 1042, customer: "Tom Reyes", email: "tom@example.com", total: 48.5, status: "pending", createdAt: "2026-10-29" },
{ id: 1043, customer: "Mei Chen", email: "mei@example.com", total: 310, status: "refunded", createdAt: "2026-10-30" },
];
const columns: QTableColumn<Order>[] = [
{ name: "id", label: "Order", field: "id", align: "left", sortable: true },
{ name: "customer", label: "Customer", field: "customer", align: "left", sortable: true },
{ name: "total", label: "Total", field: "total", sortable: true, format: (val: number) => `$${val.toFixed(2)}` },
{ name: "status", label: "Status", field: "status", align: "center", sortable: true },
{ name: "createdAt", label: "Date", field: "createdAt", sortable: true },
];
const statusColor: Record<Order["status"], string> = {
paid: "positive",
pending: "warning",
refunded: "grey-7",
};
const filter = ref("");
const pagination = ref<QTableProps["pagination"]>({
sortBy: "createdAt",
descending: true,
page: 1,
rowsPerPage: 10,
});
</script>Why does my column sort as text instead of numbers? The field holds strings like "129". Store numbers as numbers, or add a sort function that parses them.
Why is my column aligned right? align defaults to right. Set align: "left" on text columns.
How do I hide the bottom bar? Add hide-bottom (or hide-pagination to keep the bar but drop the controls). Only do that when every row fits on one page, or set rowsPerPage: 0.
How do I make a column always visible? Add required: true to it. It then stays visible when you use visible-columns to let users choose columns.
You now have a table that sorts the way users expect, searches the fields that matter, and remembers where they were. When you want a finished design instead of a bare table, the Data Tables Block has 17 Components built on the same QTable, with headings, action buttons and row actions. Two of them are Free to copy. Paste one, swap in your columns, and keep the parts from this tutorial.