Filtering Pattern
Build flexible, URL-based filtering systems for API endpoints with type-safe query builders and reusable UI components.
Overview#
Filtering allows users to narrow down data based on specific criteria. This pattern implements URL-based filtering that preserves state, enables sharing, and works with server-side rendering.
When to use:
- E-commerce product listings
- Search results pages
- Admin dashboards and data tables
- Any list view requiring dynamic filtering
Key features:
- URL-based filter state (shareable, bookmarkable)
- Type-safe filter builders
- Multi-select and range filters
- Reusable filter UI components
Code Example#
URL-Based Filters#
1// lib/filters.ts
2import { Prisma } from '@prisma/client'
3
4interface FilterParams {
5 category?: string
6 minPrice?: string
7 maxPrice?: string
8 status?: string
9 sort?: string
10 order?: 'asc' | 'desc'
11 page?: string
12 limit?: string
13}
14
15export function buildProductFilters(params: FilterParams) {
16 const where: Prisma.ProductWhereInput = {}
17
18 if (params.category) {
19 where.categoryId = params.category
20 }
21
22 if (params.minPrice || params.maxPrice) {
23 where.price = {}
24 if (params.minPrice) {
25 where.price.gte = parseFloat(params.minPrice)
26 }
27 if (params.maxPrice) {
28 where.price.lte = parseFloat(params.maxPrice)
29 }
30 }
31
32 if (params.status) {
33 where.status = params.status
34 }
35
36 const orderBy: Prisma.ProductOrderByWithRelationInput = {}
37 const sortField = params.sort || 'createdAt'
38 const sortOrder = params.order || 'desc'
39 orderBy[sortField as keyof typeof orderBy] = sortOrder
40
41 const page = parseInt(params.page ?? '1')
42 const limit = parseInt(params.limit ?? '10')
43
44 return {
45 where,
46 orderBy,
47 skip: (page - 1) * limit,
48 take: limit
49 }
50}Filter API Route#
1// app/api/products/route.ts
2import { NextRequest, NextResponse } from 'next/server'
3import { buildProductFilters } from '@/lib/filters'
4import { prisma } from '@/lib/db'
5
6export async function GET(request: NextRequest) {
7 const searchParams = request.nextUrl.searchParams
8
9 const params = {
10 category: searchParams.get('category') ?? undefined,
11 minPrice: searchParams.get('minPrice') ?? undefined,
12 maxPrice: searchParams.get('maxPrice') ?? undefined,
13 status: searchParams.get('status') ?? undefined,
14 sort: searchParams.get('sort') ?? undefined,
15 order: searchParams.get('order') as 'asc' | 'desc' ?? undefined,
16 page: searchParams.get('page') ?? undefined,
17 limit: searchParams.get('limit') ?? undefined
18 }
19
20 const { where, orderBy, skip, take } = buildProductFilters(params)
21
22 const [products, total] = await Promise.all([
23 prisma.product.findMany({
24 where,
25 orderBy,
26 skip,
27 take,
28 include: { category: true }
29 }),
30 prisma.product.count({ where })
31 ])
32
33 return NextResponse.json({
34 products,
35 pagination: {
36 page: parseInt(params.page ?? '1'),
37 limit: take,
38 total,
39 totalPages: Math.ceil(total / take)
40 }
41 })
42}Filter UI Component#
1// components/ProductFilters.tsx
2'use client'
3
4import { useRouter, useSearchParams, usePathname } from 'next/navigation'
5import { useCallback } from 'react'
6
7interface Category {
8 id: string
9 name: string
10}
11
12export function ProductFilters({ categories }: { categories: Category[] }) {
13 const router = useRouter()
14 const pathname = usePathname()
15 const searchParams = useSearchParams()
16
17 const createQueryString = useCallback(
18 (updates: Record<string, string | null>) => {
19 const params = new URLSearchParams(searchParams)
20
21 Object.entries(updates).forEach(([key, value]) => {
22 if (value === null) {
23 params.delete(key)
24 } else {
25 params.set(key, value)
26 }
27 })
28
29 // Reset to page 1 when filters change
30 if (!updates.page) {
31 params.set('page', '1')
32 }
33
34 return params.toString()
35 },
36 [searchParams]
37 )
38
39 const updateFilters = (updates: Record<string, string | null>) => {
40 router.push(`${pathname}?${createQueryString(updates)}`)
41 }
42
43 return (
44 <div className="space-y-6">
45 {/* Category filter */}
46 <div>
47 <h3 className="font-medium">Category</h3>
48 <div className="mt-2 space-y-2">
49 {categories.map(category => (
50 <label key={category.id} className="flex items-center gap-2">
51 <input
52 type="radio"
53 name="category"
54 checked={searchParams.get('category') === category.id}
55 onChange={() => updateFilters({ category: category.id })}
56 />
57 {category.name}
58 </label>
59 ))}
60 <button
61 onClick={() => updateFilters({ category: null })}
62 className="text-sm text-blue-600"
63 >
64 Clear
65 </button>
66 </div>
67 </div>
68
69 {/* Price range */}
70 <div>
71 <h3 className="font-medium">Price Range</h3>
72 <div className="mt-2 flex gap-2">
73 <input
74 type="number"
75 placeholder="Min"
76 defaultValue={searchParams.get('minPrice') ?? ''}
77 onBlur={(e) => updateFilters({ minPrice: e.target.value || null })}
78 className="w-24 rounded border px-2 py-1"
79 />
80 <input
81 type="number"
82 placeholder="Max"
83 defaultValue={searchParams.get('maxPrice') ?? ''}
84 onBlur={(e) => updateFilters({ maxPrice: e.target.value || null })}
85 className="w-24 rounded border px-2 py-1"
86 />
87 </div>
88 </div>
89
90 {/* Sort */}
91 <div>
92 <h3 className="font-medium">Sort By</h3>
93 <select
94 value={`${searchParams.get('sort') ?? 'createdAt'}-${searchParams.get('order') ?? 'desc'}`}
95 onChange={(e) => {
96 const [sort, order] = e.target.value.split('-')
97 updateFilters({ sort, order })
98 }}
99 className="mt-2 w-full rounded border px-3 py-2"
100 >
101 <option value="createdAt-desc">Newest</option>
102 <option value="createdAt-asc">Oldest</option>
103 <option value="price-asc">Price: Low to High</option>
104 <option value="price-desc">Price: High to Low</option>
105 <option value="name-asc">Name: A-Z</option>
106 <option value="name-desc">Name: Z-A</option>
107 </select>
108 </div>
109
110 {/* Clear all */}
111 <button
112 onClick={() => router.push(pathname)}
113 className="w-full rounded border py-2 hover:bg-gray-50"
114 >
115 Clear All Filters
116 </button>
117 </div>
118 )
119}Active Filters Display#
1// components/ActiveFilters.tsx
2'use client'
3
4import { useSearchParams, useRouter, usePathname } from 'next/navigation'
5import { X } from 'lucide-react'
6
7export function ActiveFilters() {
8 const searchParams = useSearchParams()
9 const router = useRouter()
10 const pathname = usePathname()
11
12 const filters = Array.from(searchParams.entries()).filter(
13 ([key]) => !['page', 'limit'].includes(key)
14 )
15
16 if (filters.length === 0) return null
17
18 const removeFilter = (key: string) => {
19 const params = new URLSearchParams(searchParams)
20 params.delete(key)
21 router.push(`${pathname}?${params.toString()}`)
22 }
23
24 return (
25 <div className="flex flex-wrap gap-2">
26 {filters.map(([key, value]) => (
27 <span
28 key={key}
29 className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-sm"
30 >
31 {key}: {value}
32 <button onClick={() => removeFilter(key)}>
33 <X className="h-3 w-3" />
34 </button>
35 </span>
36 ))}
37 </div>
38 )
39}Multi-Select Filter#
1// components/TagFilter.tsx
2'use client'
3
4import { useSearchParams, useRouter, usePathname } from 'next/navigation'
5
6export function TagFilter({ tags }: { tags: string[] }) {
7 const searchParams = useSearchParams()
8 const router = useRouter()
9 const pathname = usePathname()
10
11 const selectedTags = searchParams.getAll('tag')
12
13 const toggleTag = (tag: string) => {
14 const params = new URLSearchParams(searchParams)
15 const current = params.getAll('tag')
16
17 params.delete('tag')
18
19 if (current.includes(tag)) {
20 current.filter(t => t !== tag).forEach(t => params.append('tag', t))
21 } else {
22 current.forEach(t => params.append('tag', t))
23 params.append('tag', tag)
24 }
25
26 router.push(`${pathname}?${params.toString()}`)
27 }
28
29 return (
30 <div className="flex flex-wrap gap-2">
31 {tags.map(tag => (
32 <button
33 key={tag}
34 onClick={() => toggleTag(tag)}
35 className={`rounded-full px-3 py-1 text-sm ${
36 selectedTags.includes(tag)
37 ? 'bg-blue-600 text-white'
38 : 'bg-gray-100 hover:bg-gray-200'
39 }`}
40 >
41 {tag}
42 </button>
43 ))}
44 </div>
45 )
46}Server Component with Filters#
1// app/products/page.tsx
2import { prisma } from '@/lib/db'
3import { buildProductFilters } from '@/lib/filters'
4import { ProductFilters } from '@/components/ProductFilters'
5import { ActiveFilters } from '@/components/ActiveFilters'
6import { ProductGrid } from '@/components/ProductGrid'
7import { Pagination } from '@/components/Pagination'
8
9interface Props {
10 searchParams: { [key: string]: string | string[] | undefined }
11}
12
13export default async function ProductsPage({ searchParams }: Props) {
14 const params = {
15 category: searchParams.category as string | undefined,
16 minPrice: searchParams.minPrice as string | undefined,
17 maxPrice: searchParams.maxPrice as string | undefined,
18 sort: searchParams.sort as string | undefined,
19 order: searchParams.order as 'asc' | 'desc' | undefined,
20 page: searchParams.page as string | undefined
21 }
22
23 const { where, orderBy, skip, take } = buildProductFilters(params)
24
25 const [products, total, categories] = await Promise.all([
26 prisma.product.findMany({ where, orderBy, skip, take }),
27 prisma.product.count({ where }),
28 prisma.category.findMany()
29 ])
30
31 const totalPages = Math.ceil(total / take)
32
33 return (
34 <div className="flex gap-8">
35 <aside className="w-64">
36 <ProductFilters categories={categories} />
37 </aside>
38
39 <main className="flex-1">
40 <ActiveFilters />
41 <p className="mb-4 text-gray-600">{total} products found</p>
42 <ProductGrid products={products} />
43 <Pagination totalPages={totalPages} basePath="/products" />
44 </main>
45 </div>
46 )
47}Usage Instructions#
- Define filter parameters: Create a type for your filter options
- Build query function: Convert URL params to database query
- Create API route: Return filtered and paginated data
- Build filter components: Create UI for each filter type
- Handle filter state: Use URL search params for persistence
Best Practices#
- Use URL state - Filters should be shareable and bookmarkable
- Reset pagination on filter change - Go back to page 1 when filters change
- Show active filters - Display what filters are currently applied
- Provide clear all option - Easy way to reset all filters
- Validate filter values - Sanitize inputs before querying
- Show result counts - Indicate how many items match filters
- Debounce text inputs - Avoid excessive API calls for text filters
Related Patterns#
- Pagination - Paginate filtered results
- Route Handler - API endpoint implementation
- URL State - URL state management
- Tables - Data tables with filtering