Straktur
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
Date rangeFiltering by a date column — created, due, deliveredDataTableDateRangeFilter
SearchFree-text search across columnsDataTableSearch (built into toolbar)

Have 2+ filters? Wrap them in DataTableFilterBar — it shows only active filters as chips and tucks the rest behind a "+ Filter" picker. Recommended whenever the toolbar would otherwise show three or more filter buttons.


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 — use DataTableFilterBar

Once you have 2 or more filters, drop them into DataTableFilterBar instead of putting each one directly in filterSlot. The bar shows only the active filters as chips and tucks inactive ones behind a single "+ Filter" picker — keeps the toolbar clean as the number of filters grows. Reference implementations: examples/activities (5 filters), examples/clients (2 filters), examples/contacts (3 filters).

import {
  DataTableFilterBar,
  type FilterBarItem,
  DataTableFacetedFilter,
} from "@/components/data-table"
import { Tag, Flag, User } from "lucide-react"

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

// 1. Atomic reset — one URL update for every bar filter at once.
//    Per-filter onClear() loops would race on a stale searchParams snapshot.
const handleResetAllFilters = useCallback(
  () => resetFilters(["statusId", "priorityId", "assigneeId", "q"]),
  [resetFilters]
)

// 2. Define filters declaratively
const filterBarItems = useMemo<FilterBarItem[]>(
  () => [
    {
      id: "statusId",
      label: t("columns.status"),
      icon: Tag,
      isActive: filters.statusId.length > 0,
      alwaysVisible: true, // Pin Status — always show even when empty
      onClear: () => onFilterChange("statusId", new Set()),
      render: (controlled) => (
        <DataTableFacetedFilter
          title={t("columns.status")}
          options={statusOptions}
          selectedValues={new Set(filters.statusId)}
          onSelectionChange={(values) => onFilterChange("statusId", values)}
          {...controlled}
        />
      ),
    },
    {
      id: "priorityId",
      label: t("columns.priority"),
      icon: Flag,
      isActive: filters.priorityId.length > 0,
      onClear: () => onFilterChange("priorityId", new Set()),
      render: (controlled) => (
        <DataTableFacetedFilter
          title={t("columns.priority")}
          options={priorityOptions}
          selectedValues={new Set(filters.priorityId)}
          onSelectionChange={(values) => onFilterChange("priorityId", values)}
          {...controlled}
        />
      ),
    },
    {
      id: "assigneeId",
      label: t("columns.assignee"),
      icon: User,
      isActive: filters.assigneeId.length > 0,
      onClear: () => onFilterChange("assigneeId", new Set()),
      render: (controlled) => (
        <DataTableFacetedFilter
          title={t("columns.assignee")}
          options={assigneeOptions}
          selectedValues={new Set(filters.assigneeId)}
          onSelectionChange={(values) => onFilterChange("assigneeId", values)}
          {...controlled}
        />
      ),
    },
  ],
  [filters.statusId, filters.priorityId, filters.assigneeId, statusOptions, priorityOptions, assigneeOptions, onFilterChange, t]
)

// 3. Render
<DataTableToolbar
  filterSlot={
    <DataTableFilterBar filters={filterBarItems} onResetAll={handleResetAllFilters} />
  }
/>

FilterBarItem cheat sheet

fieldpurpose
idstable key (use the URL filter key)
labelshown in the picker dropdown
iconoptional — shown next to the label in the picker
isActivederived from URL/filter state. Drives chip visibility.
alwaysVisibleoptional. true keeps the filter in the toolbar even when empty (dashed empty-state button). Use sparingly — for the 1–2 filters central to the page.
onClearclear this filter (called by the default reset fallback if onResetAll isn't provided)
render({ open, onOpenChange })render the underlying filter component, spread controlled onto it. open is set only when the filter has just been picked from the menu — that's how it auto-opens.

Atomic reset (onResetAll)

The bar's "Reset" button defaults to looping through each filter's onClear. For URL-driven filters, this races on a stale searchParams snapshot — only the last navigation wins. Always pass onResetAll that performs one atomic update:

// All filters via the URL-state hook (skip e.g. statusCode when used by sidebar):
const handleResetAllFilters = useCallback(
  () => resetFilters(["statusId", "industryId", "q"]),
  [resetFilters]
)

// Mixed: hook filters + manual single-value params (clientId, contactId, etc.):
const handleResetAllFilters = useCallback(() => {
  const newParams = updateSearchParams(searchParams, {
    type: null, completed: null, date: null,  // hook filters
    clientId: null, contactId: null,          // manual params
    q: null, page: null,
  })
  router.push(newParams ? `${pathname}?${newParams}` : pathname)
}, [searchParams, router, pathname])

Visibility rules

staterendered in bar?offered in + Filter picker?
isActive: true✓ as chipno
isActive: false, alwaysVisible: true✓ as dashed empty buttonno (already pinned)
isActive: false, alwaysVisible: false (default)no

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.


Adding a Date Range Filter

For filtering by a date column (created, due date, delivery, etc.). Includes quick presets (last 7/30 days, this/last month, this quarter, this year) plus a custom calendar range picker. The range serializes to the URL as a single from..to value, so shareable links and reloads survive.

Step 1: Add SQL Condition

Use parseDateRange to split the URL value, then apply gte / lte conditions:

// src/features/invoices/queries.ts
import { gte, lte } from "drizzle-orm"
import { parseDateRange } from "@/lib/url-state/filters"

export async function listInvoices(params: {
  organizationId: string
  page: number
  pageSize: number
  issuedAt?: string[]  // ← Date range arrives as a single-element array
}) {
  const conditions = [eq(invoices.organizationId, params.organizationId)]

  if (params.issuedAt?.[0]) {
    const { from, to } = parseDateRange(params.issuedAt[0])
    if (from) conditions.push(gte(invoices.issuedAt, new Date(from)))
    if (to) conditions.push(lte(invoices.issuedAt, new Date(to)))
  }

  // ... rest of query
}

The value is a string[] with exactly one element (e.g. ["2026-03-01..2026-03-31"]). Keeping the type consistent with other filters means no special case in useDataTableUrlState.

Step 2: Add to Router Input Schema

// src/server/routers/invoices.ts
const listInputSchema = z.object({
  page: z.number().int().min(1).default(1),
  pageSize: z.number().int().min(1).max(100).default(20),
  q: z.string().optional(),
  issuedAt: z.array(z.string()).optional(),  // ← Single-element array
})

Step 3: Wire Up in List Page

// src/app/(dashboard)/invoices/page.tsx
import { DataTableDateRangeFilter } from "@/components/data-table"

const {
  filters, onFilterChange, resetFilters, hasActiveFilters,
  // ...other url state
} = useDataTableUrlState<{ issuedAt: string[] }>({
  tableId: "invoices",
  defaultPageSize: 20,
  filterKeys: ["issuedAt"],
})

const { data } = useQuery(
  orpcUtils.invoices.list.queryOptions({
    input: {
      page,
      pageSize,
      ...(filters.issuedAt.length > 0 && { issuedAt: filters.issuedAt }),
    },
  })
)

return (
  <DataTableToolbar
    table={table}
    filterSlot={
      <DataTableDateRangeFilter
        title={t("columns.issuedAt")}
        selectedValues={new Set(filters.issuedAt)}
        onSelectionChange={(values) => onFilterChange("issuedAt", [...values])}
      />
    }
  />
)

URL Format

/invoices?issuedAt=2026-03-01..2026-03-31

One open-ended side is fine — ?issuedAt=2026-03-01.. filters "from March 1 onwards", and ?issuedAt=..2026-03-31 filters "up to and including March 31". Use formatDateRange(from, to) from @/lib/url-state/filters when constructing ranges programmatically.

Presets

The picker ships with: last 7 days, last 30 days, this month, last month, this quarter, this year. When the current range exactly matches a preset, the pill shows the preset name (e.g. "This month") instead of the raw dates.


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