Recipes

Add Filter to List Page

How to add faceted, async, or search filters to a DataTable list

Add Filter to List Page

TL;DR: Define filter in URL state, add SQL condition, render filter component in toolbar.

Filter Types

TypeWhen to useComponent
FacetedSmall option lists — statuses, types, categories (< 100 items)DataTableFacetedFilter
AsyncLarge lists — customers, products, users (100+ items)DataTableAsyncFilter
SearchFree-text search across columnsDataTableSearch (built into toolbar)

The Checklist

#FileWhat to do
1queries.tsAdd filter param + SQL condition
2RouterAdd filter to input schema
3List pageAdd to useDataTableUrlState, render filter component
4i18nAdd filter label

Adding a Faceted Filter (e.g. Status)

Best for dictionary-based fields where all options fit in a dropdown.

Step 1: Add SQL Condition

// src/features/tasks/queries.ts
import { inArray } from "drizzle-orm"

export async function listTasks(params: {
  organizationId: string
  page: number
  pageSize: number
  q?: string
  statusId?: string[]  // ← New filter param
}) {
  const conditions = [eq(tasks.organizationId, params.organizationId)]

  if (params.q?.trim()) {
    conditions.push(ilike(tasks.name, `%${params.q.trim()}%`))
  }

  // ← Filter by status (array of dictionary value IDs)
  if (params.statusId && params.statusId.length > 0) {
    conditions.push(inArray(tasks.statusId, params.statusId))
  }

  // ... rest of query with and(...conditions)
}

Step 2: Add to Router Input Schema

// src/server/routers/tasks.ts
const listInputSchema = z.object({
  page: z.number().int().min(1).default(1),
  pageSize: z.number().int().min(1).max(100).default(20),
  sort: z.string().regex(/^[a-zA-Z]+:(asc|desc)$/).optional(),
  q: z.string().optional(),
  statusId: z.array(z.string().uuid()).optional(),  // ← New filter
})

Step 3: Wire Up in List Page

// src/app/(dashboard)/tasks/page.tsx
import { DataTableFacetedFilter } from "@/components/data-table"
import { useDictionaryValuesByCode } from "@/features/dictionaries"
import { DICTIONARY_CODES } from "@/lib/db/schema"

export default function TasksPage() {
  const t = useTranslations("tasks")
  const locale = useLocale()

  // 1. Fetch filter options from dictionary
  const { data: statusValues } = useDictionaryValuesByCode(DICTIONARY_CODES.TASK_STATUSES)

  // 2. Convert to filter options (with colored dots)
  const statusOptions = useMemo(
    () =>
      (statusValues ?? []).map((v) => ({
        value: v.id,
        label: resolveDictionaryLabel(v.label, v.labels, locale),
        color: v.color,  // Shows colored dot in dropdown
      })),
    [statusValues, locale]
  )

  // 3. URL state with filter keys
  const {
    page, pageSize, sort, q, filters,
    onSearchChange, onFilterChange,
    resetFilters, hasActiveFilters,
    sorting, pagination, onSortingChange, onPaginationChange,
  } = useDataTableUrlState<{ statusId: string[] }>({
    tableId: "tasks",
    defaultPageSize: 20,
    filterKeys: ["statusId"],
  })

  // 4. Pass filters to query
  const { data, isLoading } = useQuery(
    orpcUtils.tasks.list.queryOptions({
      input: {
        page,
        pageSize,
        ...(sort && { sort }),
        ...(q && { q }),
        ...(filters.statusId.length > 0 && { statusId: filters.statusId }),
      },
    })
  )

  // 5. Render filter in toolbar
  return (
    <DataTableToolbar
      table={table}
      searchValue={q}
      onSearchChange={onSearchChange}
      filterSlot={
        <>
          <DataTableFacetedFilter
            title={t("columns.status")}
            options={statusOptions}
            selectedValues={new Set(filters.statusId)}
            onSelectionChange={(values) => onFilterChange("statusId", values)}
          />
          {hasActiveFilters && (
            <Button variant="ghost" size="sm" onClick={() => resetFilters()}>
              {t("common.resetFilters")}
            </Button>
          )}
        </>
      }
    />
  )
}

That's it. The filter shows as a button in the toolbar, persists in the URL (?statusId=id1&statusId=id2), and resets to page 1 when changed.


Adding Multiple Filters

Just add more filter keys and components:

const {
  filters, onFilterChange, resetFilters, hasActiveFilters,
} = useDataTableUrlState<{
  statusId: string[]
  priorityId: string[]
  assigneeId: string[]
}>({
  tableId: "tasks",
  defaultPageSize: 20,
  filterKeys: ["statusId", "priorityId", "assigneeId"],
})

// In toolbar filterSlot:
<>
  <DataTableFacetedFilter
    title={t("columns.status")}
    options={statusOptions}
    selectedValues={new Set(filters.statusId)}
    onSelectionChange={(values) => onFilterChange("statusId", values)}
  />
  <DataTableFacetedFilter
    title={t("columns.priority")}
    options={priorityOptions}
    selectedValues={new Set(filters.priorityId)}
    onSelectionChange={(values) => onFilterChange("priorityId", values)}
  />
  <DataTableFacetedFilter
    title={t("columns.assignee")}
    options={assigneeOptions}  // Include avatarUrl for user avatars
    selectedValues={new Set(filters.assigneeId)}
    onSelectionChange={(values) => onFilterChange("assigneeId", values)}
  />
  {hasActiveFilters && (
    <Button variant="ghost" size="sm" onClick={() => resetFilters()}>
      Reset
    </Button>
  )}
</>

Adding an Async Filter (Large Lists)

When you have too many options to load upfront (customers, products, etc.), use DataTableAsyncFilter which searches server-side:

import { DataTableAsyncFilter } from "@/components/data-table"

<DataTableAsyncFilter
  title={t("columns.customer")}
  selectedValues={new Set(filters.customerId)}
  onSelectionChange={(values) => onFilterChange("customerId", values)}
  onSearch={async (query) => {
    // Call your search endpoint
    const results = await orpcClient.customers.search({ q: query, limit: 10 })
    return results.map((c) => ({ value: c.id, label: c.name }))
  }}
  resolveSelected={async (ids) => {
    // Resolve labels for already-selected IDs (on page load)
    const results = await orpcClient.customers.getByIds({ ids })
    return results.map((c) => ({ value: c.id, label: c.name }))
  }}
/>

Key difference from faceted: Options load on demand via onSearch (debounced 300ms). The resolveSelected callback loads names for IDs that are already in the URL when the page first renders.


Filter Option Variants

Colored Dots (for statuses)

const options = statuses.map((s) => ({
  value: s.id,
  label: s.label,
  color: s.color,  // "green", "red", "yellow", "blue", "gray"
}))

Renders as: 🟢 Active, 🔴 Closed, 🟡 Pending

With Avatars (for users/assignees)

const options = users.map((u) => ({
  value: u.id,
  label: u.name,
  avatarUrl: u.image,  // URL or null for fallback initials
}))

With Icons

import { AlertCircle, CheckCircle } from "lucide-react"

const options = [
  { value: "open", label: "Open", icon: AlertCircle },
  { value: "closed", label: "Closed", icon: CheckCircle },
]

URL Behavior

Filters persist in the URL for sharing and bookmarking:

/tasks?statusId=abc123&statusId=def456&priorityId=ghi789&q=report
  • Multi-select: same key repeated (statusId=a&statusId=b)
  • Changing a filter resets to page 1
  • Default values are omitted from URL
  • Values are sorted alphabetically for consistent URLs

Common Mistakes

Don't forget inArray for multi-value filters:

// ❌ Wrong — only matches first value
if (statusId) conditions.push(eq(tasks.statusId, statusId[0]))

// ✅ Correct — matches any selected value
if (statusId?.length) conditions.push(inArray(tasks.statusId, statusId))

Don't pass empty arrays to the query:

// ❌ Wrong — sends empty array, SQL breaks
input: { statusId: filters.statusId }

// ✅ Correct — only send when there are values
...(filters.statusId.length > 0 && { statusId: filters.statusId })

Don't forget to type useDataTableUrlState:

// ❌ Wrong — filters are untyped
useDataTableUrlState({ filterKeys: ["statusId"] })

// ✅ Correct — TypeScript knows filter shape
useDataTableUrlState<{ statusId: string[] }>({
  filterKeys: ["statusId"],
})

Verify

npm run typecheck

On this page