URL State with searchParams
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.
Reading searchParams
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', 'title']).catch('newest');Updating URL State
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 = useSearchParams();
const currentFilter = searchParams.get('filter') ?? 'all';
const currentSort = searchParams.get('sort') ?? 'newest';
function handleFilterChange(value: string) {
router.push(`/dashboard?filter=${value}&sort=${currentSort}`);
}
return (
<TabList
activeTab={currentFilter}
changeAction={handleFilterChange}
/>
);
}Optimistic URL Updates
Combine with useOptimistic for instant feedback:
const [optimisticSort, setOptimisticSort] = useOptimistic(currentSort);
function handleSortChange(nextSort: string) {
startTransition(() => {
setOptimisticSort(nextSort);
router.push(`/dashboard?filter=${currentFilter}&sort=${nextSort}`);
});
}The tab/button updates instantly; the URL changes; Suspense handles the loading state for the new data.
Benefits Over useState
| 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.