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: ```bash npm 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
...
; } ``` 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; ``` ### 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 = (schema: z.ZodType) => { 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 (
{/* Search Input */} setSearch(e.target.value)} placeholder="Search products..." className="w-full px-4 py-2 border rounded-lg" /> {/* Category Filter */} setCategory(e.target.value || null)} className="px-4 py-2 border rounded-lg" >
All Categories

Electronics

Clothing

Books

Home
{/* Price Range */}
setMinPrice(parseFloat(e.target.value) || null)} placeholder="Min price" min="0" className="px-4 py-2 border rounded-lg" /> setMaxPrice(parseFloat(e.target.value) || null)} placeholder="Max price" min="0" className="px-4 py-2 border rounded-lg" />
{/* Sort */} setSort(e.target.value || null)} className="px-4 py-2 border rounded-lg" >
Default

Price: Low to High

Price: High to Low

Name: A to Z

Name: Z to A
{/* Pagination */}
setPage(page - 1)} disabled={page <= 1} className="px-4 py-2 border rounded-lg disabled:opacity-50" > Previous Page {page} setPage(page + 1)} className="px-4 py-2 border rounded-lg" > Next
{/* Clear Filters */} { 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
); } ``` ## 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
Invalid search parameters
; } const filters = parsed.data; // Fetch data with validated params const products = await fetchProducts(filters); return (

Products
{/* Render products */}
); } ``` ## 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 = ( 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 (
updateFilter("search", e.target.value)} placeholder="Search..." /> Clear Filters
); } ``` ## Benefits of This Approach 1. **Type Safety**: TypeScript knows the exact shape of your search params 2. **Runtime Validation**: Zod ensures invalid values never reach your app 3. **Automatic URL Sync**: nuqs handles URL updates automatically 4. **Default Values**: Define sensible defaults for missing params 5. **Shareable URLs**: Users can bookmark and share filtered views 6. **Server & Client**: Works in both server and client components ## Common Patterns ### Debounced Search ```tsx import { 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 ( debouncedSetSearch(e.target.value)} placeholder="Search..." /> ); } ``` ### Multiple Filters with Reset ```tsx const [filters, setFilters] = useQueryStates({ category: categoryParser, minPrice: priceParser, maxPrice: priceParser, }); // Update multiple at once const applyFilters = (newFilters: Partial) => { 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. ## Resources - [nuqs Documentation](https://nuqs.47ng.com/) - [Zod Documentation](https://zod.dev/) - [Next.js Search Params](https://nextjs.org/docs/app/api-reference/file-conventions/page#searchparams-optional)