URL State in React: nuqs, useSearchParams and the Right Patterns
Stop losing filter state on reload. Learn how to sync React state to the URL with nuqs, useSearchParams, and patterns that actually hold up in production.
Why URL State Is Underused
Here's the thing most tutorials skip: the URL is state. It's shareable, bookmarkable, and survives a hard refresh without you writing a single line of localStorage code. Yet most React apps treat it as an afterthought and then spend weeks bolting on 'share this view' features that could've been free from day one.
Think about every time a user copies a URL from a filtered product list, sends it to a colleague, and the colleague lands on a completely different view — no filters, page 1, blank slate. That's a state management failure. It's also deeply frustrating.
In practice, you want URL state for anything the user might want to bookmark or share: active tab, search query, sort order, pagination offset, selected filters. You don't want it for volatile UI state like whether a dropdown is open or which tooltip is visible. That stuff belongs in useState — keep it there.
Worth noting: this isn't a Next.js-only problem. Plain React apps with React Router v6+ have useSearchParams too. The patterns here apply broadly, even if Next.js is where most of the nuanced edge cases live.
The Native Approach: useSearchParams
Both React Router 6+ and Next.js 13+ ship useSearchParams. It's the browser's URLSearchParams API wrapped in a hook. You can read and write query params, and the component re-renders when they change. Simple enough for a lot of cases.
import { useSearchParams } from 'next/navigation';
function ProductFilters() {
const searchParams = useSearchParams();
const sort = searchParams.get('sort') ?? 'newest';
// Read works fine. Writing is the awkward part.
return <span>Sort: {sort}</span>;
}Reading is painless. Writing is where it gets messy. In Next.js App Router, useSearchParams is read-only. To update the URL you have to reach for useRouter and router.push or router.replace, manually build the new search string, and handle every edge case yourself — like removing a param when the value is falsy, or preserving other existing params you didn't touch.
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
function SortControl() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
function setSort(value: string) {
const params = new URLSearchParams(searchParams.toString());
params.set('sort', value);
router.push(`${pathname}?${params.toString()}`);
}
return (
<select onChange={(e) => setSort(e.target.value)}>
<option value="newest">Newest</option>
<option value="price_asc">Price: Low to High</option>
</select>
);
}That works. It's also 14 lines of boilerplate for what should be a 3-line operation. Multiply this across a real filter panel with 6 params and you're looking at a mess. Type safety? Zero — everything's a string. Parsing an array? DIY. Default values? Also on you. This is exactly the gap nuqs fills.
Enter nuqs: Type-Safe URL State
nuqs (formerly next-usequerystate) is a small library — around 4.5kB gzipped as of version 2.x — that gives you a useState-like API for URL query parameters, with parsing, serialization, and type safety baked in. It works with Next.js App Router, Pages Router, and React Router.
npm install nuqsThe basic API is dead simple. useQueryState returns a tuple just like useState, but the value is synced to the URL:
import { useQueryState } from 'nuqs';
function SortControl() {
const [sort, setSort] = useQueryState('sort', { defaultValue: 'newest' });
return (
<select
value={sort}
onChange={(e) => setSort(e.target.value)}
>
<option value="newest">Newest</option>
<option value="price_asc">Price: Low to High</option>
</select>
);
}That's it. The URL updates, the component re-renders, and when the user lands on ?sort=price_asc from a bookmark, they get price_asc — not 'newest' because you forgot to parse the param. Honest opinion: the DX jump from raw useSearchParams to nuqs is bigger than almost any other small library swap I've done in recent years.
One more thing — nuqs ships parsers for the types you actually need. parseAsInteger, parseAsBoolean, parseAsArrayOf, parseAsJson. Stop manually doing parseInt(searchParams.get('page') ?? '1') in five places.
Handling Multiple Params: useQueryStates
When you're building a real filter panel — say, category, price range, tags, page number, sort order — you don't want five separate useQueryState calls. nuqs provides useQueryStates for managing a group of params together as a single object.
import { useQueryStates, parseAsInteger, parseAsArrayOf, parseAsString } from 'nuqs';
const filterParsers = {
page: parseAsInteger.withDefault(1),
sort: parseAsString.withDefault('newest'),
tags: parseAsArrayOf(parseAsString).withDefault([]),
minPrice: parseAsInteger.withDefault(0),
};
function FilterPanel() {
const [filters, setFilters] = useQueryStates(filterParsers);
function resetFilters() {
setFilters({ page: 1, sort: 'newest', tags: [], minPrice: 0 });
}
return (
<div>
<p>Page: {filters.page}</p>
<p>Tags: {filters.tags.join(', ')}</p>
<button onClick={resetFilters}>Reset</button>
</div>
);
}The setFilters call is a shallow merge by default — you only pass the keys you want to change, the rest stay put. You can override this with { shallow: false } to trigger a full navigation for server-component revalidation in Next.js.
Quick aside: the shallow option is probably the most important nuqs option you'll use in Next.js App Router. shallow: true (the default) updates the URL without triggering a server round-trip — perfect for client-side UI state. shallow: false triggers a full navigation and causes Server Components to re-render with the new params. Use false when your server component reads from searchParams to fetch data.
Server Components and searchParams
In Next.js 13+ App Router, page-level Server Components receive searchParams as a prop. This is the canonical way to do data fetching based on URL state — no client-side hook needed, no extra waterfall.
// app/products/page.tsx
export default async function ProductsPage({
searchParams,
}: {
searchParams: { sort?: string; page?: string; tags?: string };
}) {
const sort = searchParams.sort ?? 'newest';
const page = parseInt(searchParams.page ?? '1', 10);
const tags = searchParams.tags?.split(',') ?? [];
const products = await fetchProducts({ sort, page, tags });
return <ProductList products={products} />;
}The client components (your filter panel, pagination controls) update the URL via nuqs. The Server Component reads the resulting URL on navigation and fetches fresh data. It's a clean separation: client handles interaction, server handles data. Honestly, this is one of the few architectural patterns in Next.js App Router that feels genuinely good once it clicks.
One gotcha: searchParams in Server Components isn't typed beyond Record<string, string | string[] | undefined>. You'll want to validate and parse it yourself, or use something like Zod. nuqs has a server-side utility called createSearchParamsCache for exactly this — it lets you define the same parsers once and use them both on the server and in client hooks.
import { createSearchParamsCache, parseAsInteger, parseAsString } from 'nuqs/server';
export const searchParamsCache = createSearchParamsCache({
page: parseAsInteger.withDefault(1),
sort: parseAsString.withDefault('newest'),
});
// In your Server Component:
const { page, sort } = searchParamsCache.parse(searchParams);That's type-safe param parsing on the server, with the same defaults and parsers your client hooks use. No duplication, no drift.
Common Patterns and Pitfalls
A few things that bite people. First, don't put everything in the URL. Modal open/close state, hover state, animation state — keep that local. The URL is for state that makes sense to a user who just arrived from a link. If it wouldn't be useful in a bookmark, it probably shouldn't be a query param.
Second, watch out for URL length. Arrays serialized as ?tags=a&tags=b&tags=c can get long fast. You might want to comma-separate them (?tags=a,b,c) or encode them differently for filters with many possible values. nuqs handles both via parseAsArrayOf with a custom separator option.
Third: transition performance. Every router.push in Next.js App Router causes a navigation. If you're updating params on every keystroke in a search input, you'll hammer the server. Debounce is your friend:
import { useQueryState, parseAsString } from 'nuqs';
import { useDebouncedCallback } from 'use-debounce';
function SearchInput() {
const [query, setQuery] = useQueryState('q', parseAsString.withDefault(''));
const [inputValue, setInputValue] = React.useState(query);
const debouncedSetQuery = useDebouncedCallback(setQuery, 300);
return (
<input
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
debouncedSetQuery(e.target.value);
}}
/>
);
}That said, you can also use nuqs's built-in throttleMs option if you want to avoid the extra dependency. The pattern above gives you the snappy local input feel while keeping URL updates reasonable. Pair this kind of pattern with a thoughtful component API design and you've got something maintainable long-term.
Choosing the Right Tool for the Job
Look, you don't always need nuqs. If you have one or two simple string params and no type concerns, raw useSearchParams plus a helper function is fine. The overhead of adding a dependency isn't always worth it for trivial cases.
That said, the moment you're managing more than two params, need arrays or numbers, or want server/client parity on parsing — reach for nuqs. It's actively maintained, has solid Next.js App Router support, and the API surface is small enough that you won't spend a week reading docs. Introduced in 2023, it's now one of the most-starred Next.js utility libraries.
For global UI state that also needs to be bookmarkable — think a dark/light mode that a user might want to share as a link — you can combine URL state with a regular state manager. Read from the URL on mount, keep the in-memory state as the source of truth for rendering, and write back to the URL on change. Zustand makes this particularly clean; there's a decent example in the zustand-react-guide.
If you're building something more interactive — a drag-and-drop dashboard, a kanban board, a multi-step form — you might find that the URL state layer handles persistence/sharing while something like Zustand or React Context handles the live interaction state. They're not competing; they're complementary layers. You can see this in action if you've ever built a data table with filters where filter state lives in the URL but selection state lives locally.
The big picture: URL state is a free, native, web-native persistence layer that most apps underuse. nuqs makes it ergonomic. The React hooks complete guide has solid context on the mental model if you want to think more deeply about where state belongs. And if you're building polished UIs where state-driven interactions need to look great, browse components to see how Empire UI components handle selection, filter, and tab state in ways that pair cleanly with URL-driven patterns.
FAQ
Both expose the URLSearchParams API, but Next.js App Router's version is read-only — you can't call .set() on it. You need useRouter to push updates. React Router's version returns a setter, so it's closer to useState out of the box.
Yes, nuqs supports Pages Router explicitly. You'll use the pages adapter. The API is the same; the underlying navigation call switches from router.push to the Pages Router equivalent automatically.
No. By default nuqs uses shallow routing — the URL updates without a server round-trip. Set shallow: false only when you need Server Components to re-render with the new params.
Use parseAsArrayOf(parseAsString) from nuqs. By default it serializes as repeated params (?tag=a&tag=b), but you can pass a custom separator string to get comma-delimited values instead (?tags=a,b).