URL State Management Patterns

Manage application state in URL search parameters for shareable, bookmarkable pages.

Overview#

URL state enables deep linking and sharing. This pattern covers:

  • Basic URL state hook
  • Typed URL state with Zod
  • Pagination state
  • Filter state
  • Server Component integration

Prerequisites#

No additional dependencies - uses Next.js built-in hooks.

Basic URL State Hook#

A reusable hook for managing URL search params.

1// hooks/useUrlState.ts 2'use client' 3 4import { useSearchParams, usePathname, useRouter } from 'next/navigation' 5import { useCallback, useMemo } from 'react' 6 7export function useUrlState<T extends Record<string, string | string[] | undefined>>( 8 defaults?: Partial<T> 9) { 10 const searchParams = useSearchParams() 11 const pathname = usePathname() 12 const router = useRouter() 13 14 const state = useMemo(() => { 15 const params: Record<string, string | string[]> = {} 16 17 searchParams.forEach((value, key) => { 18 const existing = params[key] 19 if (existing) { 20 params[key] = Array.isArray(existing) 21 ? [...existing, value] 22 : [existing, value] 23 } else { 24 params[key] = value 25 } 26 }) 27 28 return { ...defaults, ...params } as T 29 }, [searchParams, defaults]) 30 31 const setParams = useCallback( 32 (updates: Partial<T>, options?: { replace?: boolean; scroll?: boolean }) => { 33 const params = new URLSearchParams(searchParams.toString()) 34 35 Object.entries(updates).forEach(([key, value]) => { 36 if (value === undefined || value === null || value === '') { 37 params.delete(key) 38 } else if (Array.isArray(value)) { 39 params.delete(key) 40 value.forEach(v => params.append(key, v)) 41 } else { 42 params.set(key, String(value)) 43 } 44 }) 45 46 const url = `${pathname}?${params.toString()}` 47 48 if (options?.replace) { 49 router.replace(url, { scroll: options?.scroll ?? false }) 50 } else { 51 router.push(url, { scroll: options?.scroll ?? false }) 52 } 53 }, 54 [searchParams, pathname, router] 55 ) 56 57 const clearParams = useCallback( 58 (keys?: string[]) => { 59 const params = new URLSearchParams(searchParams.toString()) 60 61 if (keys) { 62 keys.forEach(key => params.delete(key)) 63 } else { 64 for (const key of params.keys()) { 65 params.delete(key) 66 } 67 } 68 69 router.push(`${pathname}?${params.toString()}`) 70 }, 71 [searchParams, pathname, router] 72 ) 73 74 return { state, setParams, clearParams } 75}

Typed URL State with Zod#

Validate and parse URL parameters with Zod schemas.

1// hooks/useTypedUrlState.ts 2'use client' 3 4import { useSearchParams, usePathname, useRouter } from 'next/navigation' 5import { useCallback, useMemo } from 'react' 6import { z } from 'zod' 7 8export function useTypedUrlState<T extends z.ZodObject<any>>(schema: T) { 9 type State = z.infer<T> 10 11 const searchParams = useSearchParams() 12 const pathname = usePathname() 13 const router = useRouter() 14 15 const state = useMemo(() => { 16 const raw: Record<string, any> = {} 17 18 searchParams.forEach((value, key) => { 19 raw[key] = value 20 }) 21 22 // Parse and validate with schema 23 const result = schema.safeParse(raw) 24 25 if (result.success) { 26 return result.data as State 27 } 28 29 // Return defaults from schema 30 return schema.parse({}) as State 31 }, [searchParams, schema]) 32 33 const setParams = useCallback( 34 (updates: Partial<State>) => { 35 const params = new URLSearchParams(searchParams.toString()) 36 37 Object.entries(updates).forEach(([key, value]) => { 38 if (value === undefined || value === null) { 39 params.delete(key) 40 } else if (typeof value === 'boolean') { 41 params.set(key, value ? 'true' : 'false') 42 } else if (typeof value === 'number') { 43 params.set(key, String(value)) 44 } else { 45 params.set(key, String(value)) 46 } 47 }) 48 49 router.push(`${pathname}?${params.toString()}`, { scroll: false }) 50 }, 51 [searchParams, pathname, router] 52 ) 53 54 return { state, setParams } 55} 56 57// Usage 58const filterSchema = z.object({ 59 search: z.string().optional().default(''), 60 page: z.coerce.number().optional().default(1), 61 sort: z.enum(['newest', 'oldest', 'popular']).optional().default('newest'), 62 category: z.string().optional() 63}) 64 65function ProductList() { 66 const { state, setParams } = useTypedUrlState(filterSchema) 67 68 return ( 69 <div> 70 <input 71 value={state.search} 72 onChange={e => setParams({ search: e.target.value, page: 1 })} 73 placeholder="Search..." 74 /> 75 <select 76 value={state.sort} 77 onChange={e => setParams({ sort: e.target.value as any })} 78 > 79 <option value="newest">Newest</option> 80 <option value="oldest">Oldest</option> 81 <option value="popular">Popular</option> 82 </select> 83 </div> 84 ) 85}

Pagination Component#

A reusable pagination component using URL state.

1// components/Pagination.tsx 2'use client' 3 4import { useUrlState } from '@/hooks/useUrlState' 5import { ChevronLeft, ChevronRight } from 'lucide-react' 6import { cn } from '@/lib/utils' 7 8interface PaginationProps { 9 totalPages: number 10 pageParam?: string 11} 12 13export function Pagination({ totalPages, pageParam = 'page' }: PaginationProps) { 14 const { state, setParams } = useUrlState({ [pageParam]: '1' }) 15 const currentPage = parseInt(state[pageParam] as string) || 1 16 17 const goToPage = (page: number) => { 18 setParams({ [pageParam]: String(page) }) 19 } 20 21 const pages = getPageNumbers(currentPage, totalPages) 22 23 return ( 24 <nav className="flex items-center gap-1"> 25 <button 26 onClick={() => goToPage(currentPage - 1)} 27 disabled={currentPage <= 1} 28 className="rounded p-2 hover:bg-gray-100 disabled:opacity-50" 29 > 30 <ChevronLeft className="h-4 w-4" /> 31 </button> 32 33 {pages.map((page, i) => 34 page === '...' ? ( 35 <span key={`ellipsis-${i}`} className="px-2"> 36 ... 37 </span> 38 ) : ( 39 <button 40 key={page} 41 onClick={() => goToPage(page as number)} 42 className={cn( 43 'rounded px-3 py-1', 44 currentPage === page 45 ? 'bg-blue-600 text-white' 46 : 'hover:bg-gray-100' 47 )} 48 > 49 {page} 50 </button> 51 ) 52 )} 53 54 <button 55 onClick={() => goToPage(currentPage + 1)} 56 disabled={currentPage >= totalPages} 57 className="rounded p-2 hover:bg-gray-100 disabled:opacity-50" 58 > 59 <ChevronRight className="h-4 w-4" /> 60 </button> 61 </nav> 62 ) 63} 64 65function getPageNumbers(current: number, total: number): (number | string)[] { 66 if (total <= 7) { 67 return Array.from({ length: total }, (_, i) => i + 1) 68 } 69 70 if (current <= 3) { 71 return [1, 2, 3, 4, '...', total] 72 } 73 74 if (current >= total - 2) { 75 return [1, '...', total - 3, total - 2, total - 1, total] 76 } 77 78 return [1, '...', current - 1, current, current + 1, '...', total] 79}

Filter Component#

Build filter interfaces with URL state.

1// components/ProductFilters.tsx 2'use client' 3 4import { useUrlState } from '@/hooks/useUrlState' 5 6interface FilterState { 7 search?: string 8 category?: string 9 minPrice?: string 10 maxPrice?: string 11 inStock?: string 12 sort?: string 13} 14 15export function ProductFilters() { 16 const { state, setParams, clearParams } = useUrlState<FilterState>({ 17 sort: 'newest' 18 }) 19 20 const hasActiveFilters = 21 state.search || 22 state.category || 23 state.minPrice || 24 state.maxPrice || 25 state.inStock 26 27 return ( 28 <div className="space-y-4"> 29 {/* Search */} 30 <input 31 type="text" 32 placeholder="Search..." 33 value={state.search ?? ''} 34 onChange={e => setParams({ search: e.target.value || undefined })} 35 className="w-full rounded border px-3 py-2" 36 /> 37 38 {/* Category */} 39 <select 40 value={state.category ?? ''} 41 onChange={e => setParams({ category: e.target.value || undefined })} 42 className="w-full rounded border px-3 py-2" 43 > 44 <option value="">All Categories</option> 45 <option value="electronics">Electronics</option> 46 <option value="clothing">Clothing</option> 47 <option value="books">Books</option> 48 </select> 49 50 {/* Price Range */} 51 <div className="flex gap-2"> 52 <input 53 type="number" 54 placeholder="Min price" 55 value={state.minPrice ?? ''} 56 onChange={e => setParams({ minPrice: e.target.value || undefined })} 57 className="w-1/2 rounded border px-3 py-2" 58 /> 59 <input 60 type="number" 61 placeholder="Max price" 62 value={state.maxPrice ?? ''} 63 onChange={e => setParams({ maxPrice: e.target.value || undefined })} 64 className="w-1/2 rounded border px-3 py-2" 65 /> 66 </div> 67 68 {/* In Stock */} 69 <label className="flex items-center gap-2"> 70 <input 71 type="checkbox" 72 checked={state.inStock === 'true'} 73 onChange={e => 74 setParams({ inStock: e.target.checked ? 'true' : undefined }) 75 } 76 /> 77 In Stock Only 78 </label> 79 80 {/* Clear Filters */} 81 {hasActiveFilters && ( 82 <button 83 onClick={() => 84 clearParams(['search', 'category', 'minPrice', 'maxPrice', 'inStock']) 85 } 86 className="text-sm text-blue-600 hover:underline" 87 > 88 Clear all filters 89 </button> 90 )} 91 </div> 92 ) 93}

Server Component Integration#

Use URL params in Server Components.

1// app/products/page.tsx 2import { prisma } from '@/lib/db' 3import { ProductFilters } from '@/components/ProductFilters' 4import { Pagination } from '@/components/Pagination' 5 6interface SearchParams { 7 search?: string 8 category?: string 9 minPrice?: string 10 maxPrice?: string 11 sort?: string 12 page?: string 13} 14 15export default async function ProductsPage({ 16 searchParams 17}: { 18 searchParams: SearchParams 19}) { 20 const page = parseInt(searchParams.page ?? '1') 21 const limit = 20 22 23 // Build Prisma query from URL params 24 const where: any = {} 25 26 if (searchParams.search) { 27 where.OR = [ 28 { name: { contains: searchParams.search, mode: 'insensitive' } }, 29 { description: { contains: searchParams.search, mode: 'insensitive' } } 30 ] 31 } 32 33 if (searchParams.category) { 34 where.category = searchParams.category 35 } 36 37 if (searchParams.minPrice || searchParams.maxPrice) { 38 where.price = {} 39 if (searchParams.minPrice) where.price.gte = parseFloat(searchParams.minPrice) 40 if (searchParams.maxPrice) where.price.lte = parseFloat(searchParams.maxPrice) 41 } 42 43 // Sort 44 const orderBy: any = {} 45 switch (searchParams.sort) { 46 case 'price-asc': orderBy.price = 'asc'; break 47 case 'price-desc': orderBy.price = 'desc'; break 48 default: orderBy.createdAt = 'desc' 49 } 50 51 const [products, total] = await Promise.all([ 52 prisma.product.findMany({ 53 where, 54 orderBy, 55 skip: (page - 1) * limit, 56 take: limit 57 }), 58 prisma.product.count({ where }) 59 ]) 60 61 const totalPages = Math.ceil(total / limit) 62 63 return ( 64 <div className="container py-8"> 65 <div className="grid grid-cols-4 gap-8"> 66 <aside> 67 <ProductFilters /> 68 </aside> 69 70 <main className="col-span-3"> 71 <p className="mb-4 text-sm text-gray-600">{total} products found</p> 72 73 <div className="grid grid-cols-3 gap-4"> 74 {products.map(product => ( 75 <ProductCard key={product.id} product={product} /> 76 ))} 77 </div> 78 79 <Pagination totalPages={totalPages} /> 80 </main> 81 </div> 82 </div> 83 ) 84}

Best Practices#

  1. Reset pagination on filter change - Set page to 1 when filters change
  2. Use replace for ephemeral state - Use replace: true for frequently changing values
  3. Validate server-side - Always validate URL params on the server
  4. Provide defaults - Define sensible defaults for all parameters
  5. Keep URLs clean - Remove empty parameters from the URL