why SearchParams are often the better choice
Filters, sorting, pagination and active tabs end up in useState in many React applications, even though they are really part of the address. Storing that state in the URL instead gives you deep linking, a working browser back button and shareable links for free, without an extra store.
Table of Contents
- 1. Why URL state often beats useState
- 2. Native SearchParams with useSearchParams
- 3. nuqs: type safe query parameters without boilerplate
- 4. Browser history: using push versus replace correctly
- 5. Combining several independent states in one URL
- 6. Server side rendering and URL state
- 7. Limits of the pattern: what does not belong in the URL
- 8. Combining URL state with client and server state
- 9. URL state compared to useState and Context
- 10. Summary
- 11. FAQ
1. Why URL state often beats useState
A product list with filters for category, price range and sorting is implemented with useState in many React applications. That works at first glance, but has a decisive downside: the state only exists in the current page's memory. Reloading the page resets the filters, a shared link shows the recipient the unfiltered list, and the browser back button does not step between filter states but leaves the page entirely. The URL as state pattern solves all of these problems by writing filter state, pagination and active tabs directly into the address.
The URL has always been the native place for addressable state on the web, which is exactly what query parameters were originally invented for. When state is consistently kept in SearchParams instead of useState, every combination of filter, sort and page automatically becomes its own shareable, bookmarkable address. For SEO relevant listing pages, the URL as state pattern is often the only way to produce indexable, unique URLs per filter combination at all.
Switching to URL state does not mean useState disappears entirely. Purely visual, short lived interaction state such as an open dropdown menu still belongs in local component state. The rule of thumb: anything a user would understand as part of the currently displayed view and would want to share with others belongs in the URL, anything purely internal stays in useState.
2. Native SearchParams with useSearchParams
Both React Router and Next.js offer a useSearchParams hook that returns the current query parameters as a URLSearchParams object and provides a setter function to update them. Unlike useState, an update here triggers an actual navigation, so the browser's address bar stays in sync with the displayed state without developers having to manually manipulate window.history.
The native approach, however, requires manual parsing and serialization: a numeric filter comes back as a string from the URL, an array of selected categories has to be encoded and decoded by hand, and a missing parameter has to be explicitly caught with a default value. For simple cases with one or two parameters this is manageable, but with more complex filter forms involving several types the boilerplate quickly becomes unwieldy.
// components/ProductFilters.jsx — native useSearchParams, manual parsing required
import { useSearchParams } from 'react-router-dom';
function ProductFilters() {
const [searchParams, setSearchParams] = useSearchParams();
const category = searchParams.get('category') ?? 'all';
const page = Number(searchParams.get('page') ?? '1'); // manual parsing to number
function setCategory(value) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('category', value);
next.set('page', '1'); // reset pagination when the filter changes
return next;
});
}
return (
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="all">All categories</option>
<option value="shoes">Shoes</option>
<option value="bags">Bags</option>
</select>
);
}
3. nuqs: type safe query parameters without boilerplate
nuqs is a specialized library that takes over exactly this parsing and serialization while offering an API that feels almost identical to useState. Instead of working with URLSearchParams manually, useQueryState defines a parameter with a parser, for example parseAsInteger or parseAsArrayOf, and directly returns the typed value along with a setter function, exactly like a normal React state hook.
A central feature of nuqs is its built in debounce and throttle support for updating the URL: for a live search field that would otherwise change the URL on every keystroke, the shallow option combined with a configurable timeout prevents a new history entry from being created for every character. For Next.js and React Router, nuqs ships dedicated adapters that handle integration with each routing system.
// components/ProductFilters.jsx — nuqs feels like useState but syncs to the URL
import { useQueryState, parseAsInteger, parseAsStringEnum } from 'nuqs';
function ProductFilters() {
const [category, setCategory] = useQueryState(
'category',
parseAsStringEnum(['all', 'shoes', 'bags']).withDefault('all')
);
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
function handleCategoryChange(value) {
setCategory(value);
setPage(1); // reset pagination alongside the filter change
}
return (
<select value={category} onChange={(e) => handleCategoryChange(e.target.value)}>
<option value="all">All categories</option>
<option value="shoes">Shoes</option>
<option value="bags">Bags</option>
</select>
);
}
4. Browser history: using push versus replace correctly
A frequently overlooked detail of the URL as state pattern is the choice between pushState and replaceState. Writing every filter change to history with push means the browser's back button steps through every single filter change instead of returning to the previous page, which for a debounced live search field quickly produces a frustrating dozen history entries for a single input.
The pragmatic rule: deliberate, discrete user actions such as switching a category or toggling a sort order justify a push entry, because the user plausibly wants to navigate back to that state. Continuous input such as typing in a search field or dragging a price slider should instead be updated with replace, so history is not flooded with intermediate states. Both the native History API and nuqs support this distinction through an explicit option per update.
5. Combining several independent states in one URL
Once a page manages several independent pieces of URL state at the same time, for example filter, sort, pagination and an active tab, coordinating between these parameters becomes important. nuqs offers useQueryStates for that, which lets several related parameters be read as one object and written in a single atomic update, instead of risking multiple history entries or race conditions from several separate calls.
A practical example: when the category changes, pagination also needs to reset to page one at the same time. If both parameters are updated with two separate setter calls, depending on batching behavior this can result in two separate navigations. With useQueryStates, the entire update can be performed as one object in a single call, resulting in only one URL change and one history entry.
// components/ProductListState.jsx — coordinate multiple related query params atomically
import { useQueryStates, parseAsInteger, parseAsStringEnum } from 'nuqs';
function useProductListState() {
return useQueryStates({
category: parseAsStringEnum(['all', 'shoes', 'bags']).withDefault('all'),
sort: parseAsStringEnum(['price', 'newest']).withDefault('newest'),
page: parseAsInteger.withDefault(1),
});
}
function ProductList() {
const [{ category, sort, page }, setState] = useProductListState();
function changeCategory(value) {
// Single atomic update — one navigation, one history entry
setState({ category: value, page: 1 });
}
return <p>{category} / {sort} / page {page}</p>;
}
6. Server side rendering and URL state
A key advantage of URL state over client side state shows up with server side rendering: because the query parameters are already available in the initial request to the server, a Next.js server component can load the correctly filtered data set directly on the first render, without waiting for a client side effect and a second request. This substantially improves both time to content and the consistency between server and client rendered output.
In the Next.js App Router architecture, SearchParams are passed directly to server components as a searchParams prop, which lets filter logic move partly or entirely to the server, while the client remains responsible only for interactively updating the URL. This combination of server side initial rendering and client side URL manipulation is one of the main reasons the URL as state pattern works so well in modern React frameworks.
7. Limits of the pattern: what does not belong in the URL
Not every piece of state belongs in the URL. Sensitive data such as authentication tokens or personal information must never appear as query parameters, because URLs end up in browser history, server logs and analytics tools and are therefore effectively not confidential. Very large amounts of data are equally unsuitable, for example the entire content of a multi step form, because browsers and servers enforce practical length limits for URLs, usually somewhere between two and eight kilobytes depending on browser and server configuration.
Purely transient UI state such as a modal's animation or an input field's focus state should also stay out of the URL, because it has no addressable value for the user and would needlessly fill the URL with technical noise. The dividing line runs clearly along the question of whether a piece of state is part of what a user understands as the current view of the application and would want to share via a link.
8. Combining URL state with client and server state
In practice, URL state almost never exists in isolation, but as one building block alongside server state via TanStack Query and occasional client state via Zustand or Context. A typical pattern: the current filter values come from the URL via nuqs, get passed as a query key to useQuery, and TanStack Query handles caching and deduplication for the actually loaded product data.
This combination ensures that changing filters automatically triggers a new, correctly cached query, while the browser back button reliably returns to the previous filter state along with its already cached result. The URL acts as the single source of truth for the current filter state, and TanStack Query is responsible exclusively for the associated server data.
9. URL state compared to useState and Context
The table below shows which kind of state the URL is the right fit for, compared to classic component state and Context.
| Criterion | useState | Context | URL / SearchParams |
|---|---|---|---|
| Survives page reload | No | No | Yes, always |
| Shareable via link | No | No | Yes, natively |
| Browser back works | No | No | Yes, via history |
| Suitable for sensitive data | Suitable | Suitable | Unsuitable |
| Server component access (SSR) | Not possible | Not possible | Directly via searchParams prop |
Mironsoft
React architecture, state management and modern frontend infrastructure
Filters that vanish on every reload?
We move filters, pagination and tab state to where they belong, in the URL, with nuqs, clean history handling and direct server component integration for Next.js.
Filter migration
Move existing useState filters to SearchParams and nuqs
SSR integration
Feed server components directly with searchParams for a faster first paint
TanStack Query pairing
Use URL state as a query key for consistent caching of server data
10. Summary
The URL as state pattern moves filters, sorting, pagination and tab state from useState into the address bar, which automatically produces deep linking, a working browser back button and shareable links. Native useSearchParams hooks work fine for simple cases, but require manual parsing and serialization. nuqs takes over that work with a useState like API, type safe parsers and built in support for debouncing as well as coordinating several related parameters.
Not every piece of state belongs in the URL: sensitive data, very large amounts of data and purely transient UI state are better kept in useState or a client store. Combined with server side rendering and TanStack Query for the associated server data, the URL becomes the single reliable source of truth for everything a user would understand as the current, addressable view of the application and want to share.
URL as state: the essentials at a glance
When to use URL state
For filters, sorting, pagination and tabs that should be shareable, bookmarkable and survive a reload.
nuqs over useSearchParams
Type safe parsers, a useState like API and automatic serialization replace manual URLSearchParams handling.
push versus replace
Discrete actions with push, continuous input with replace, to keep browser history from flooding.
Limits of the pattern
Sensitive data, large amounts of data and purely transient UI state do not belong in the URL.