Type-Safe Search Params in Next.js with nuqs and Zod
Learn how to build type-safe, validated URL search parameters in Next.js using nuqs for state management and Zod for runtime validation.
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:
npm install nuqs zod
# or
pnpm add nuqs zodThe Problem Without Validation
Here's what typical search param handling looks like:
"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:
// 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:
// 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:
"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:
// 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:
// 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:
"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
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 (
<input
type="text"
defaultValue={search || ""}
onChange={(e) => debouncedSetSearch(e.target.value)}
placeholder="Search..."
/>
);
}Multiple Filters with Reset
const [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.