Filtering and sorting

Advanced filters, default filter values, column sorting, quick filters and pinned filter chips.

Advanced filters

defineListConfig<Product>({
  id: 'products',
  search: true,
  filtersTitle: 'Filter products',
  filters: [
    {
      id: 'attributes',
      title: 'Attributes',
      filters: [
        {
          id: 'category',
          field: 'category',
          label: 'Category',
          type: 'select',
          options: [{ value: 'coffee', label: 'Coffee' }],
        },
        {
          id: 'tags',
          field: 'tags',
          label: 'Tags',
          type: 'multi-select',
          options: [...],
        },
        {
          id: 'price',
          field: 'price',
          label: 'Price',
          type: 'number-range',
        },
        {
          id: 'createdAt',
          field: 'createdAt',
          label: 'Created',
          type: 'date-range',
        },
        {
          id: 'active',
          field: 'active',
          label: 'Status',
          type: 'boolean',
        },
        {
          id: 'name',
          field: 'name',
          label: 'Name',
          type: 'text',
        },
      ],
    },
  ],
})

Applied filters appear as removable chips above the list and sync to the URL. With an async adapter, read query.filters (an ActiveFilterValue[]) in your fetcher and translate to SQL/HTTP.

Default filter values

Give any filter a defaultValue to pre-apply it on a pristine list — when the URL has no filters yet. Multiple filters can each set one (e.g. show only active rows and default to the current month):

const filters: FilterDefinition<Order>[] = [
	{
		id: 'status',
		field: 'status',
		label: 'Status',
		type: 'select',
		options: statusOptions,
		defaultValue: 'active',
	},
	{
		id: 'created',
		field: 'createdAt',
		label: 'Created',
		type: 'date-range',
		defaultValue: { from: '2026-06-01', to: '2026-06-30' },
	},
]

defaultValue uses the same shape the adapter receives for that type: selectstring, multi-selectstring[], booleanboolean, text{ value, match }, date-range{ from?, to? }, number-range{ min?, max? }.

A date-range the picker produces carries absolute instants (…T06:00:00.000Z), not bare days: the operator picks a local day, and only the client knows their timezone, so it converts before the value travels — from at local 00:00, to at local 23:59:59.999. Left as a bare YYYY-MM-DD, the server read it as UTC midnight and shifted the whole window for anyone west of UTC. A hand-written YYYY-MM-DD still works and still means the whole day: both the in-memory matcher and the Mongo builder extend a date-only to to end-of-day, and neither touches a value that already carries a time.

Defaults seed the initial view only: they're applied on the first render (so the first fetch already includes them) and written to the URL, after which the user's edits/clears always win. Lists rendered with initialData (SSR) are left as-is — apply the defaults in your server query instead.

Column sorting

Mark any table column sortable. Clicking its header cycles ascending → descending → off, syncs the active sort to a sort URL param, and flows into the adapter as query.sort ({ field, dir }):

table: {
  columns: [
    { key: 'name', header: 'Name', sortable: true },
    { key: 'createdAt', header: 'Created', sortable: true, sortField: 'created_at' },
    { key: 'total', header: 'Total', align: 'right', sortable: true },
  ],
}
  • In-memory data — the built-in adapter sorts automatically by the active field.
  • Async adapters — read query.sort in your fetcher and translate to ORDER BY.
  • sortField overrides the field name sent to the adapter (defaults to the column key).

After a page loads, the next page is prefetched on idle into the cache, so clicking "next" renders instantly with no loading flash.

Quick filters

Mark a filter quick and it renders as a compact pill under the search box that opens its real input in a popover — the frequently-used filters without a trip to the sidebar. Users hide the bar from the Options menu.

{ id: 'channel', field: 'channel', label: 'Channel', type: 'select', options, quick: true }

quick and pinned complement each other: a pinned chip toggles one predetermined value (only unpaid), a quick pill lets the user pick any value the filter accepts. Both stay ordinary filters — same URL param, same query.filters entry, same cache key.

The sidebar also reorders itself around use: applied filters lead their section, sections holding them lead the panel, and long untouched sections start collapsed (never one holding an applied filter). Turn either off with filtersActiveFirst: false / filtersAutoCollapse: false.

Pinned filter chips

Some filters are the list ("only unpaid", "active users"). Mark one pinned and it also renders as a toggleable chip above the rows:

{ id: 'paid', field: 'paid', label: 'Paid', type: 'boolean', pinned: true },
{ id: 'status', field: 'status', label: 'Status', type: 'select',
  options, pinned: true, pinnedValue: 'pending' },

Clicking applies pinnedValue (or defaultValue, or true for a boolean); clicking again clears it. It stays an ordinary filter — same URL param, same query.filters entry, same cache key — so nothing else in the list needs to know.

On this page