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#

  1. Define filter parameters: Create a type for your filter options
  2. Build query function: Convert URL params to database query
  3. Create API route: Return filtered and paginated data
  4. Build filter components: Create UI for each filter type
  5. Handle filter state: Use URL search params for persistence

Best Practices#

  1. Use URL state - Filters should be shareable and bookmarkable
  2. Reset pagination on filter change - Go back to page 1 when filters change
  3. Show active filters - Display what filters are currently applied
  4. Provide clear all option - Easy way to reset all filters
  5. Validate filter values - Sanitize inputs before querying
  6. Show result counts - Indicate how many items match filters
  7. Debounce text inputs - Avoid excessive API calls for text filters