50% off for the first 50 customers. Only 10 left. Use code
40/50 claimed
See pricing →
November 1, 2026

Quasar QTable Tutorial: Sort, Filter, Paginate

A Quasar QTable tutorial that builds one orders table step by step: typed columns, sorting, a search filter, pagination and a status badge cell.
Quasar
QTable
Data Tables
Vue

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.

The data and the columns

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.

A minimal Quasar QTable

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.

QTable sorting

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:

  • two numbers are compared as numbers;
  • two Date objects are compared as dates;
  • two booleans are compared as booleans;
  • anything else is turned into a lowercase string and compared as text.

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:

  1. It searches the formatted value, so typing $129 finds the first order.
  2. It searches visible columns only. Our 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" />

QTable pagination

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.

When the data lives on the server

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.

A status cell slot

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.

The full component

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>

Common QTable questions

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.

Next step

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.