Headline: URL state means storing UI state — filters, active tab, search query, pagination — in the URL query string instead of React
useState, so a refresh, a shared link, or the back button restores exactly what the user saw. In the Next.js App Router I read it withuseSearchParams()or the asyncsearchParamsprop, write it withrouter.replace(), and reach fornuqswhen I want a type-safe,useState-shaped API. The trap that cost me an afternoon: a component callinguseSearchParams()must sit inside a React<Suspense>boundary or the production build fails.
A dashboard I built kept its filter panel in useState. Users would filter to a subset, refresh to pull fresh data, and lose every selection. The fix was not more state management — it was less: move the state a user expects to survive a reload into the URL, where the browser persists it for free.
Key takeaways
- URL state stores UI state in the URL query string, so a refresh, a shared link, or the back button restores what the user saw;
useStateis lost on every reload. - Read query state with
useSearchParams()in a client component, or the asyncsearchParamsprop on a server component (a Promise youawaitin Next.js 15). - Write it with
router.replace()and aURLSearchParamsobject; usereplacenotpushso filter tweaks do not flood the back-button history. - The
nuqslibrary gives auseState-shaped hook,useQueryState, with typed parsers likeparseAsIntegerinstead of raw strings. - A component calling
useSearchParams()must be wrapped in<Suspense>during static rendering, or the Next.js build errors.
When should I store state in the URL instead of useState?
Store state in the URL when the user expects it to survive a refresh, a shared link, or a bookmark — filters, the active tab, a search term, sort order, and pagination all qualify. Keep state in useState when it is ephemeral: a hover state, whether a dropdown is open, an unsaved form draft. My test is one question: if the user copies this URL and sends it to a colleague, should the colleague see the same view? If yes, it belongs in the URL. URL state also kills a class of sync bugs — there is one source of truth, the query string, instead of a useState value that drifts from the address bar.
How do I read search params in the Next.js App Router?
In a client component, call useSearchParams() from next/navigation, which returns a read-only URLSearchParams.
'use client';
import { useSearchParams } from 'next/navigation';
function StatusBadge() {
const searchParams = useSearchParams();
const status = searchParams.get('status') ?? 'all';
return <span>{status}</span>;
}
In a server component, skip the hook and read the searchParams prop. In Next.js 15 it is a Promise, so you await it and fetch the correct data before rendering.
export default async function Page({
searchParams,
}: {
searchParams: Promise<{ status?: string }>;
}) {
const { status = 'all' } = await searchParams;
const rows = await getRows(status);
return <Table rows={rows} />;
}
How do I write to the URL without triggering a full navigation?
Build a fresh URLSearchParams from the current params, set your key, and call router.replace(). Seeding from the existing params preserves every other query key.
'use client';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
function StatusFilter() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
function setStatus(value: string) {
const params = new URLSearchParams(searchParams);
params.set('status', value);
router.replace(`${pathname}?${params.toString()}`);
}
return (
<select onChange={(e) => setStatus(e.target.value)}>
<option value="all">All</option>
<option value="open">Open</option>
</select>
);
}
Use router.replace(), not router.push(). push adds a history entry per change, so the back button steps through each tweak; replace keeps one entry.
What is nuqs, and what does useQueryState add?
nuqs is a type-safe search-params state manager for React that turns the read-plus-router.replace dance into one useState-shaped hook. useQueryState(key, parser) returns a [value, setValue] tuple, but the value lives in the URL.
'use client';
import { useQueryState, parseAsInteger } from 'nuqs';
function ProductSearch() {
const [query, setQuery] = useQueryState('q', { defaultValue: '' });
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
return (
<input value={query} onChange={(e) => setQuery(e.target.value || null)} />
);
}
The parsers are the win: parseAsInteger, parseAsBoolean, parseAsArrayOf, parseAsStringEnum, and parseAsIsoDateTime convert the URL string into the type your component wants and back on write. Setting a value to null clears the key. In nuqs v2 you wrap the app once in <NuqsAdapter> from nuqs/adapters/next/app, and batch several keys with useQueryStates.
Native searchParams or nuqs — which should I pick?
| Concern | Native useSearchParams | nuqs |
|---|---|---|
| Types | Strings only, parse yourself | Typed parsers (int, bool, array, enum, date) |
| Setup | None |
<NuqsAdapter> wrapper once |
| Write API | Manual URLSearchParams + router.replace
|
useState-like tuple |
| Many keys | Hand-built query string |
useQueryStates batches them |
| Defaults | Manual ?? fallback
|
withDefault + clearOnDefault
|
| Rapid writes | Debounce yourself | Built-in throttling |
What breaks — Suspense, throttling, and stale server data?
The build aborts with "useSearchParams() should be wrapped in a suspense boundary". During static rendering, any component reading useSearchParams() must sit inside a <Suspense> boundary, because the params are only known at request time.
import { Suspense } from 'react';
<Suspense fallback={<FiltersSkeleton />}>
<StatusFilter />
</Suspense>
Two more edges. Writing to the URL on every keystroke spams history and re-renders — nuqs throttles by default and exposes throttleMs; with native code you debounce yourself. And whether a URL change re-runs server components depends on shallow routing: nuqs defaults to shallow: true (client-only URL update), so set shallow: false when a server component filters through the searchParams prop and needs the new value.
FAQ
Q: When should I keep state in the URL versus useState?
A: URL for state that should survive a refresh, shared link, or bookmark — filters, tabs, search, sort, pagination. useState for ephemeral UI like open menus, hover, or an unsaved form draft.
Q: Do I need nuqs, or is useSearchParams enough?
A: useSearchParams plus router.replace covers reading and writing. nuqs adds typed parsers, a useState-like API, multi-key batching, and throttling. One or two string params: native. Many typed params: nuqs.
Q: Why does my build fail with "useSearchParams() should be wrapped in a suspense boundary"?
A: During static rendering Next.js requires any component calling useSearchParams() to be inside a <Suspense> boundary, since the value is only known at request time. Wrap that component.
Q: Should I use router.push or router.replace for filter changes?
A: router.replace. push adds a history entry per change, so the back button walks through every tweak; replace keeps one entry.
Q: Does updating the URL re-run my server components?
A: With native router.replace, yes. With nuqs it depends on the shallow option: default shallow: true is client-only, shallow: false triggers the server refetch.
Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.
Top comments (0)