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 |
| Date range | Filtering by a date column — created, due, delivered | DataTableDateRangeFilter |
| Search | Free-text search across columns | DataTableSearch (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
| # | 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 — 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
| field | purpose |
|---|---|
id | stable key (use the URL filter key) |
label | shown in the picker dropdown |
icon | optional — shown next to the label in the picker |
isActive | derived from URL/filter state. Drives chip visibility. |
alwaysVisible | optional. 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. |
onClear | clear 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
| state | rendered in bar? | offered in + Filter picker? |
|---|---|---|
isActive: true | ✓ as chip | no |
isActive: false, alwaysVisible: true | ✓ as dashed empty button | no (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-31One 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 typecheckRelated
- Add New Feature — Set up the list page first
- Add Dictionary — Create dictionary for filter options