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#
- Reset pagination on filter change - Set page to 1 when filters change
- Use replace for ephemeral state - Use
replace: truefor frequently changing values - Validate server-side - Always validate URL params on the server
- Provide defaults - Define sensible defaults for all parameters
- Keep URLs clean - Remove empty parameters from the URL
Related Patterns#
- Tables - Data tables with URL state
- React Query - Cache queries by URL params
- Tabs - URL-synced tabs