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
| Type | When to use | Component |
|---|---|---|
| Faceted | Small option lists — statuses, types, categories (< 100 items) | DataTableFacetedFilter |
| Async | Large lists — customers, products, users (100+ items) | DataTableAsyncFilter |
| Search | Free-text search across columns | DataTableSearch (built into toolbar) |
The Checklist
| # | File | What to do |
|---|---|---|
| 1 | queries.ts | Add filter param + SQL condition |
| 2 | Router | Add filter to input schema |
| 3 | List page | Add to useDataTableUrlState, render filter component |
| 4 | i18n | Add 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 typecheckRelated
- Add New Feature — Set up the list page first
- Add Dictionary — Create dictionary for filter options