Type-Safe Search Params in Next.js with nuqs and Zod
Managing URL search parameters in Next.js can get messy fast. You're dealing with string parsing, type coercion, validation, and keeping your UI in sync with the URL. What if you could handle all of this with type safety and minimal boilerplate?
Enter nuqs and Zod — a powerful combination that makes URL state management feel like working with regular React state, but with validation baked in.
Why This Matters
URL search parameters are great for:
- Shareable filtered views
- Bookmarkable application states
- SEO-friendly pagination
- Deep linking into specific app states
But they come with challenges:
- Everything is a string
- No type safety by default
- Manual parsing and validation
- Keeping URL and UI in sync
Let's fix all of that.
What We're Building
We'll build a product listing page with:
- Search query
- Category filter
- Price range
- Sorting options
- Pagination
All synced to the URL with full type safety and validation.
Setup
First, install the dependencies:
bashnpm install nuqs zod # or pnpm add nuqs zod
The Problem Without Validation
Here's what typical search param handling looks like:
tsx"use client"; import { useSearchParams } from "next/navigation"; export default function ProductList() { const searchParams = useSearchParams(); // Everything is a string or null const search = searchParams.get("search"); // string | null const page = searchParams.get("page"); // string | null const minPrice = searchParams.get("minPrice"); // string | null // Manual parsing and validation const pageNumber = page ? parseInt(page) : 1; const minPriceNumber = minPrice ? parseFloat(minPrice) : 0; // No validation - what if page is negative? // What if minPrice is not a number? // What if category is invalid? return <div>...</div>; }
This approach has issues:
- No type safety
- No validation
- Verbose parsing logic
- Easy to introduce bugs
Solution: nuqs + Zod
Step 1: Define Your Schema
Create a Zod schema for your search params:
ts// lib/search-params.ts import { z } from "zod"; export const productSearchSchema = z.object({ search: z.string().optional(), category: z.enum(["electronics", "clothing", "books", "home"]).optional(), minPrice: z.number().min(0).optional(), maxPrice: z.number().min(0).optional(), sort: z.enum(["price-asc", "price-desc", "name-asc", "name-desc"]).optional(), page: z.number().int().min(1).default(1), limit: z.number().int().min(1).max(100).default(20), }); export type ProductSearchParams = z.infer<typeof productSearchSchema>;
Step 2: Create nuqs Parsers
nuqs provides built-in parsers, but we'll create custom ones with Zod validation:
ts// lib/search-params.ts (continued) import { createParser } from "nuqs"; // Custom parser with Zod validation export const createZodParser = <T>(schema: z.ZodType<T>) => { return createParser({ parse: (value: string) => { const result = schema.safeParse(value); return result.success ? result.data : null; }, serialize: (value: T) => String(value), }); }; // Category parser with enum validation export const categoryParser = createParser({ parse: (value: string) => { const result = productSearchSchema.shape.category.safeParse(value); return result.success ? result.data : null; }, serialize: (value: string) => value, }); // Number parser with validation export const priceParser = createParser({ parse: (value: string) => { const num = parseFloat(value); if (isNaN(num) || num < 0) return null; return num; }, serialize: (value: number) => String(value), }); // Sort parser export const sortParser = createParser({ parse: (value: string) => { const result = productSearchSchema.shape.sort.safeParse(value); return result.success ? result.data : null; }, serialize: (value: string) => value, }); // Page parser with default export const pageParser = createParser({ parse: (value: string) => { const num = parseInt(value); return num > 0 ? num : 1; }, serialize: (value: number) => String(value), }).withDefault(1);
Step 3: Use in Your Component
Now use these parsers with nuqs hooks:
tsx"use client"; import { useQueryState, useQueryStates } from "nuqs"; import { categoryParser, priceParser, sortParser, pageParser, } from "@/lib/search-params"; export default function ProductList() { // Individual state hooks const [search, setSearch] = useQueryState("search"); const [category, setCategory] = useQueryState("category", categoryParser); const [minPrice, setMinPrice] = useQueryState("minPrice", priceParser); const [maxPrice, setMaxPrice] = useQueryState("maxPrice", priceParser); const [sort, setSort] = useQueryState("sort", sortParser); const [page, setPage] = useQueryState("page", pageParser); // Or use useQueryStates for multiple params const [filters, setFilters] = useQueryStates({ search: { defaultValue: "" }, category: categoryParser, minPrice: priceParser, maxPrice: priceParser, sort: sortParser, page: pageParser, }); return ( <div className="space-y-6"> {/* Search Input */} <input type="text" value={search || ""} onChange={(e) => setSearch(e.target.value)} placeholder="Search products..." className="w-full px-4 py-2 border rounded-lg" /> {/* Category Filter */} <select value={category || ""} onChange={(e) => setCategory(e.target.value || null)} className="px-4 py-2 border rounded-lg" > <option value="">All Categories</option> <option value="electronics">Electronics</option> <option value="clothing">Clothing</option> <option value="books">Books</option> <option value="home">Home</option> </select> {/* Price Range */} <div className="flex gap-4"> <input type="number" value={minPrice || ""} onChange={(e) => setMinPrice(parseFloat(e.target.value) || null)} placeholder="Min price" min="0" className="px-4 py-2 border rounded-lg" /> <input type="number" value={maxPrice || ""} onChange={(e) => setMaxPrice(parseFloat(e.target.value) || null)} placeholder="Max price" min="0" className="px-4 py-2 border rounded-lg" /> </div> {/* Sort */} <select value={sort || ""} onChange={(e) => setSort(e.target.value || null)} className="px-4 py-2 border rounded-lg" > <option value="">Default</option> <option value="price-asc">Price: Low to High</option> <option value="price-desc">Price: High to Low</option> <option value="name-asc">Name: A to Z</option> <option value="name-desc">Name: Z to A</option> </select> {/* Pagination */} <div className="flex gap-2"> <button onClick={() => setPage(page - 1)} disabled={page <= 1} className="px-4 py-2 border rounded-lg disabled:opacity-50" > Previous </button> <span className="px-4 py-2">Page {page}</span> <button onClick={() => setPage(page + 1)} className="px-4 py-2 border rounded-lg" > Next </button> </div> {/* Clear Filters */} <button onClick={() => { setSearch(null); setCategory(null); setMinPrice(null); setMaxPrice(null); setSort(null); setPage(1); }} className="px-4 py-2 bg-gray-200 rounded-lg" > Clear All Filters </button> </div> ); }
Advanced: Server-Side Validation
For server components, validate search params before using them:
tsx// app/products/page.tsx import { z } from "zod"; import { productSearchSchema } from "@/lib/search-params"; interface PageProps { searchParams: { [key: string]: string | string[] | undefined }; } export default async function ProductsPage({ searchParams }: PageProps) { // Parse and validate search params const parsed = productSearchSchema.safeParse({ search: searchParams.search, category: searchParams.category, minPrice: searchParams.minPrice ? parseFloat(searchParams.minPrice as string) : undefined, maxPrice: searchParams.maxPrice ? parseFloat(searchParams.maxPrice as string) : undefined, sort: searchParams.sort, page: searchParams.page ? parseInt(searchParams.page as string) : 1, limit: searchParams.limit ? parseInt(searchParams.limit as string) : 20, }); if (!parsed.success) { // Handle validation errors return <div>Invalid search parameters</div>; } const filters = parsed.data; // Fetch data with validated params const products = await fetchProducts(filters); return ( <div> <h1>Products</h1> {/* Render products */} </div> ); }
Advanced Pattern: Custom Hook
Create a reusable hook for your search params:
ts// hooks/use-product-filters.ts import { useQueryStates } from "nuqs"; import { categoryParser, priceParser, sortParser, pageParser, type ProductSearchParams, } from "@/lib/search-params"; export function useProductFilters() { const [filters, setFilters] = useQueryStates({ search: { defaultValue: "" }, category: categoryParser, minPrice: priceParser, maxPrice: priceParser, sort: sortParser, page: pageParser, }); const clearFilters = () => { setFilters({ search: null, category: null, minPrice: null, maxPrice: null, sort: null, page: 1, }); }; const updateFilter = <K extends keyof ProductSearchParams>( key: K, value: ProductSearchParams[K], ) => { setFilters({ [key]: value, page: 1 }); // Reset to page 1 on filter change }; return { filters, setFilters, clearFilters, updateFilter, }; }
Use it in your component:
tsx"use client"; import { useProductFilters } from "@/hooks/use-product-filters"; export default function ProductList() { const { filters, updateFilter, clearFilters } = useProductFilters(); return ( <div> <input type="text" value={filters.search || ""} onChange={(e) => updateFilter("search", e.target.value)} placeholder="Search..." /> <button onClick={clearFilters}>Clear Filters</button> </div> ); }
Benefits of This Approach
- Type Safety: TypeScript knows the exact shape of your search params
- Runtime Validation: Zod ensures invalid values never reach your app
- Automatic URL Sync: nuqs handles URL updates automatically
- Default Values: Define sensible defaults for missing params
- Shareable URLs: Users can bookmark and share filtered views
- Server & Client: Works in both server and client components
Common Patterns
Debounced Search
tsximport { useQueryState } from "nuqs"; import { useDebouncedCallback } from "use-debounce"; export function SearchInput() { const [search, setSearch] = useQueryState("search"); const debouncedSetSearch = useDebouncedCallback( (value: string) => setSearch(value || null), 300, ); return ( <input type="text" defaultValue={search || ""} onChange={(e) => debouncedSetSearch(e.target.value)} placeholder="Search..." /> ); }
Multiple Filters with Reset
tsxconst [filters, setFilters] = useQueryStates({ category: categoryParser, minPrice: priceParser, maxPrice: priceParser, }); // Update multiple at once const applyFilters = (newFilters: Partial<typeof filters>) => { setFilters({ ...newFilters, page: 1 }); }; // Reset all const resetFilters = () => { setFilters({ category: null, minPrice: null, maxPrice: null, }); };
Conclusion
Combining nuqs and Zod gives you the best of both worlds: the simplicity of React state management with the power of URL-based state and runtime validation.
Your search params are now:
- Type-safe at compile time
- Validated at runtime
- Automatically synced to the URL
- Easy to work with
No more string parsing, no more manual validation, no more bugs from invalid URL parameters.