URL State and Router Integration
The URL Is State You're Ignoring
Here's a litmus test for your app: can a user copy the URL, send it to a colleague, and the colleague sees the exact same view? Same filters, same sort order, same page, same expanded panels?
If the answer is no, you have state that should be in the URL but isn't. Every time you store a filter in useState instead of searchParams, you break three web fundamentals:
- Shareability — the URL doesn't capture the current view
- Bookmarkability — saving the URL doesn't save the state
- Back/forward navigation — the browser can't undo state changes
The URL is the original state manager. It's been syncing state across clients since 1991. And for an entire category of state — filters, pagination, sort order, tabs, expanded sections — it's still the best tool.
Think of the URL as a serialized snapshot of your view. Every query parameter is a piece of state, and the URL is the single source of truth. When the URL changes, the view updates. When the view needs to change, you update the URL. There's no separate store, no sync logic, no "two sources of truth" bugs. The URL IS the state, and React reads from it like any other state source.
useSearchParams in Next.js
The built-in hook for reading URL search parameters:
'use client';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
function ProductFilters() {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const category = searchParams.get('category') ?? 'all';
const sort = searchParams.get('sort') ?? 'relevance';
const page = Number(searchParams.get('page')) ?? 1;
function updateParam(key: string, value: string) {
const params = new URLSearchParams(searchParams.toString());
params.set(key, value);
if (key !== 'page') params.set('page', '1');
router.push(`${pathname}?${params.toString()}`);
}
return (
<div>
<select value={category} onChange={(e) => updateParam('category', e.target.value)}>
<option value="all">All</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
<select value={sort} onChange={(e) => updateParam('sort', e.target.value)}>
<option value="relevance">Relevance</option>
<option value="price">Price</option>
<option value="newest">Newest</option>
</select>
</div>
);
}
This works, but it has problems: manual string parsing, no type safety, verbose update logic, and every router.push triggers a full server-side re-render in the App Router.
nuqs: Type-Safe URL State
nuqs (pronounced 'nukes') is a library that makes URL search params behave like React state with full type safety. It's used by Vercel, Sentry, Supabase, and Clerk.
'use client';
import { useQueryState, parseAsInteger, parseAsStringEnum } from 'nuqs';
const sortOptions = ['relevance', 'price', 'newest'] as const;
function ProductFilters() {
const [category, setCategory] = useQueryState('category', { defaultValue: 'all' });
const [sort, setSort] = useQueryState(
'sort',
parseAsStringEnum(sortOptions).withDefault('relevance')
);
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
return (
<div>
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="all">All</option>
<option value="electronics">Electronics</option>
</select>
<select value={sort} onChange={(e) => setSort(e.target.value as typeof sortOptions[number])}>
<option value="relevance">Relevance</option>
<option value="price">Price</option>
</select>
<button onClick={() => setPage((p) => p + 1)}>Next Page</button>
</div>
);
}
The API feels exactly like useState, but the state lives in the URL. parseAsInteger automatically converts the string "3" to the number 3. parseAsStringEnum restricts values to the allowed set.
Shallow Updates
By default, nuqs updates the URL without triggering a server re-render (shallow mode). This is critical for responsive filters:
const [query, setQuery] = useQueryState('q', {
shallow: true,
throttleMs: 300,
});
shallow: true means the URL updates, the browser history entries are created, but Next.js doesn't re-render Server Components. Combined with throttleMs, you get debounced URL updates while the user types — no server round-trips until they stop.
When you actually need server data based on the new params, set shallow: false:
const [page, setPage] = useQueryState('page', {
...parseAsInteger.withDefault(1),
shallow: false,
});
Encoding Complex State in URLs
Sometimes you need more than simple key-value pairs. Here's how to handle complex state:
Arrays
import { parseAsArrayOf, parseAsString } from 'nuqs';
const [tags, setTags] = useQueryState(
'tags',
parseAsArrayOf(parseAsString, ',').withDefault([])
);
// URL: ?tags=react,typescript,nextjs
// Value: ['react', 'typescript', 'nextjs']
JSON-Encoded Objects
import { parseAsJson } from 'nuqs';
import { z } from 'zod';
const filterSchema = z.object({
priceMin: z.number(),
priceMax: z.number(),
inStock: z.boolean(),
});
const [filters, setFilters] = useQueryState(
'filters',
parseAsJson(filterSchema.parse)
);
// URL: ?filters={"priceMin":0,"priceMax":100,"inStock":true}
URLs should stay under 2000 characters for broad compatibility. Don't encode large datasets or deeply nested objects. If your URL state exceeds a few hundred characters, consider whether all of it truly needs to be shareable.
Server Component Access
nuqs supports reading URL state in Server Components without prop drilling:
import { createSearchParamsCache, parseAsInteger, parseAsString } from 'nuqs/server';
const searchParamsCache = createSearchParamsCache({
category: parseAsString.withDefault('all'),
page: parseAsInteger.withDefault(1),
});
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { category, page } = searchParamsCache.parse(await searchParams);
const products = await fetchProducts({ category, page });
return <ProductList products={products} />;
}
Type-safe, validated search params in Server Components — no manual parsing or as casts.
When URL State Beats React State
| Scenario | React State (useState/Zustand) | URL State (nuqs/searchParams) |
|---|---|---|
| User shares current view | Lost — recipient sees defaults | Preserved — URL captures everything |
| User bookmarks the page | Lost — state resets on load | Preserved — state is in the URL |
| User hits back button | Lost — can't undo state changes | Works — browser history tracks URL changes |
| Page refresh | Lost — state resets | Preserved — URL survives refresh |
| SSR/SSG | Not available on server | Available as searchParams in Server Components |
| Performance for rapid updates | Instant — no URL sync | Slight overhead — URL serialization + history |
| Private state (sidebar toggle) | Perfect — no URL pollution | Overkill — not everything belongs in the URL |
The rule: if the user would benefit from sharing or bookmarking a specific view, the state belongs in the URL. Filters, sort order, pagination, active tab, search query, expanded/collapsed sections — all URL state candidates.
Common Patterns
Reset Page on Filter Change
When the user changes a filter, reset to page 1:
const [category, setCategory] = useQueryState('category');
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
function handleCategoryChange(newCategory: string) {
setCategory(newCategory);
setPage(1);
}
Batch URL Updates
Update multiple params at once to avoid multiple history entries:
import { useQueryStates, parseAsString, parseAsInteger } from 'nuqs';
const [filters, setFilters] = useQueryStates({
category: parseAsString.withDefault('all'),
sort: parseAsString.withDefault('relevance'),
page: parseAsInteger.withDefault(1),
});
function resetAllFilters() {
setFilters({
category: 'all',
sort: 'relevance',
page: 1,
});
}
useQueryStates batches all updates into a single URL change and a single history entry.
| What developers do | What they should do |
|---|---|
| Reading URL params into a Redux/Zustand store on mount, then using the store as the source of truth Two sources of truth always drift. The URL will say ?page=3 but your store says page 5. Bugs that are incredibly hard to debug. | Read directly from the URL with useSearchParams or nuqs. The URL IS the source of truth. |
| Storing every piece of state in the URL (sidebar open, tooltip visible, dropdown expanded) URL pollution makes URLs ugly, hard to read, and can hit length limits. Transient UI state (tooltips, dropdowns) doesn't belong in the URL. | Only store state that benefits from shareability, bookmarkability, or back-button navigation |
| Using router.push for every keystroke in a search input Every router.push creates a history entry and potentially triggers a server re-render. 10 keystrokes = 10 history entries the user has to back through. Throttle or debounce. | Use nuqs with throttleMs or debounce the URL update |
- 1URL state is for shareable, bookmarkable, back-button-navigable state: filters, sort, pagination, search, active tab.
- 2The URL IS the source of truth. Never copy URL params into a separate store.
- 3Use nuqs for type-safe URL state with automatic parsing, validation, and shallow updates.
- 4Shallow updates change the URL without server re-renders — essential for responsive filter UIs.
- 5Batch related URL updates (useQueryStates) to avoid multiple history entries.
- 6Not everything belongs in the URL. Transient UI state (tooltips, modals, dropdowns) should stay in React state.