searchParams for shareable state, Zod validation, optimistic URL updates.
URL-driven state creates clear causality: tab click → URL changes → Suspense shows skeleton → new data arrives. Users understand what's happening because the URL reflects the current view.
More importantly, URL state is shareable and bookmarkable. /dashboard?filter=drafts&sort=title shows exactly that view—send someone the link and they see what you see.
In Server Components, searchParams is a promise that resolves to the current query parameters:
type Props = {
searchParams: Promise<{ filter?: string; sort?: string }>;
};
export async function PostList({ searchParams }: Props) {
const { filter, sort } = await searchParams;
// Validate with Zod for type safety
const validFilter = filterSchema.parse(filter);
const validSort = sortSchema.parse(sort);
const posts = await getPosts(validFilter, validSort);
return <PostCards posts={posts} />;
}Use Zod schemas with .catch() to provide defaults for invalid or missing params:
const filterSchema = z.enum(['all', 'published', 'drafts', 'archived']).catch('all');
const sortSchema = z.enum(['newest', 'oldest'In Client Components, use router.push to change the URL:
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
export function PostTabs() {
const router = useRouter();
const searchParams =
Combine with useOptimistic for instant feedback:
const [optimisticSort, setOptimisticSort] = useOptimistic(currentSort);
function handleSortChange(nextSort: string) {
startTransition(() => {
setOptimisticSort(nextSortThe tab/button updates instantly; the URL changes; Suspense handles the loading state for the new data.
| URL State | Component State |
|---|---|
| Shareable via link | Lost on refresh |
| Works with browser history | No back/forward |
| Survives refresh | Resets to default |
| SEO-friendly | Not indexable |
For filter, sort, pagination, and view modes—use URLs. For ephemeral UI state like modal open/close—use component state.