Client-side navigation hooks
The four next/navigation hooks a Client Component uses to read and change the URL, and the rule of reading URL state on the server while only writing it from the client.
Back on the invoice list, picture the interaction the last lesson promised but never built. A row of status chips sits above the table: Draft, Paid, Overdue. The user clicks Paid. The URL slides from ?status=draft to ?status=paid with no white flash, the table re-renders to show only paid invoices, and the back button brings Draft back.
You already built the server half last lesson: the page reads searchParams, validates them, queries the database, and renders the filtered table. The same URL in produces the same page out. But that page only reads the URL; it never changes it. So who turns the click on a chip into ?status=paid?
That is the job of four hooks from next/navigation: useRouter, usePathname, useSearchParams, and useParams. They split the work cleanly: the server reads the URL, the client changes it. This lesson ends with the chip handler that completes the previous page, and most of it is plain event handling rather than hook calls.
The four hooks at a glance
Section titled “The four hooks at a glance”Here is the whole toolbox in one place.
import { useRouter, usePathname, useSearchParams, useParams,} from 'next/navigation';
const router = useRouter(); // { push, replace, back, forward, refresh, prefetch } — the write hookconst pathname = usePathname(); // current path as a string, no query, no hashconst searchParams = useSearchParams(); // ReadonlyURLSearchParams for the current queryconst params = useParams(); // the route's dynamic segments as an objectOne hook writes; three read. useRouter navigates; the other three just report what’s currently in the URL.
All four are Client Component hooks. Call one from a Server Component and your build fails: they need 'use client' at the top of the file. That is the module-boundary rule you already know, interactivity lives at the smallest leaf that needs it, and moving the user to a new URL is as interactive as it gets.
useSearchParams returns a ReadonlyURLSearchParams , the same URLSearchParams you met earlier with get, getAll, and has, but the type drops set and delete. You’ll work around that when you build a query string later.
Read on the server, navigate on the client
Section titled “Read on the server, navigate on the client”Coming from single-page apps, your reflex when something needs to be interactive is to pull everything to the client: read searchParams on the client, derive the filtered data in a useEffect, hold the result in useState. That instinct quietly rebuilds the request waterfall the previous lesson deleted. So before reaching for any of these hooks, ask one question:
Does this component need to write to the URL, or react to URL changes for its own rendering?
If the answer is no, it needs no hooks at all. It reads params and searchParams from props on the server and stays a Server Component. The hooks are for the interactive leaf that initiates a navigation, the thing the user clicks, not for reading state you could have read on the server.
The two shapes below produce the same filtered list on screen, but they are not the same thing.
'use client';
export function InvoiceList() { const searchParams = useSearchParams(); const status = searchParams.get('status'); const [invoices, setInvoices] = useState<Invoice[]>([]);
useEffect(() => { fetch(`/api/invoices?status=${status}`) .then((res) => res.json()) .then(setInvoices); }, [status]);
return <InvoiceTable invoices={invoices} />;}Rebuilds the request waterfall the last lesson deleted: a client fetch the browser can’t start until the JS loads, a loading flicker on every filter change, and no server caching. The component reads the URL on the client only to ask the server for data it could have rendered directly.
// page.tsx — Server Componentexport default async function InvoicesPage({ searchParams }: PageProps) { const { status } = parseInvoiceParams(await searchParams); const invoices = await listInvoices({ status });
return ( <> <StatusFilter current={status} /> <InvoiceTable invoices={invoices} /> </> );}The server owns the read; the client owns the write. One round-trip, the query runs where the data lives, no loading flicker, and the result can be cached. StatusFilter is a tiny 'use client' leaf that receives the current status and only changes the URL on click, after which the page re-renders on the server with the new value.
The left version feels natural and is wrong; the right version has a shape worth naming. The client surface is the smallest leaf that initiates the navigation, and everything above it stays on the server. This extends the reflex you’ve been building: read high, pass resolved values down. The page reads the URL up top and passes the resolved status down as a prop, so the filter chip reads its own active state from that prop. It needs no hook to find out which filter is active, because the server already told it.
useRouter: navigating from the client
Section titled “useRouter: navigating from the client”useRouter is the write hook, the only one of the four that moves the user. Call it and you get a router object back.
const router = useRouter();router.push('/dashboard?status=paid');push is a soft navigation . In order, it updates the URL in the address bar, adds an entry to the browser’s history stack, fetches and renders the new route’s Server Components with no full document reload, then scrolls to the top. It’s the programmatic version of clicking a <Link>: same soft navigation, same prefetching, triggered from your code instead of a click.
Scroll-to-top is right when you’re moving to a new page. It’s wrong for an in-page filter change: clicking a chip halfway down a list and getting yanked to the top is jarring. So filter and sort handlers pass { scroll: false }:
router.push('/dashboard?status=paid', { scroll: false });push vs replace: what the back button should do
Section titled “push vs replace: what the back button should do”Reaching for push everywhere is one of the most common beginner mistakes. The mechanical difference is one line:
pushadds a new history entry on top of the stack.replaceswaps the current entry: the same URL change, but no new frame.
The decision isn’t about mechanics. It’s about what the user expects the back button to do, and that splits the two cases cleanly:
- Use
replacefor filter, sort, and pagination changes. Someone toggling through five filters shouldn’t press back five times to escape the page; they expect “back” to leave the list, not rewind their last chip click. - Use
pushfor genuine navigation between distinct views, like opening an invoice or moving to a settings page. Each is a place the user would expect to return to.
router.push('?status=paid', { scroll: false });Same URL change, but a new history entry. Every filter change leaves a footprint, so five toggles cost five back-presses to escape.
router.replace('?status=paid', { scroll: false });The current entry is overwritten instead, so history stays clean and back does what the user means: leave the list.
The rule to keep: if the user wouldn’t think of it as “a place I navigated to,” use replace.
A product page has a sidebar of category links, and each category page shows a row of price-range filters. The user clicks into a category, then toggles three price filters in a row. They expect a single press of the back button to land them back on the previous category — not on the same category with one price filter undone. Which call goes where?
push for the category link, replace for each price filter.push for the category link and push for each price filter too.replace for the category link, replace for each price filter.replace for the category link, push for each price filter.push. The three price filters are adjustments within that destination; if each one pushed a frame, escaping would cost four back-presses (three filters plus the category). replace overwrites the top frame instead, keeping the filters off the stack, so one press of back returns to the previous category.router.refresh: re-rendering without changing the URL
Section titled “router.refresh: re-rendering without changing the URL”Sometimes you want the server to render the current page again without moving anywhere. That’s router.refresh: it re-fetches and re-renders the current route’s Server Components and leaves the URL where it is. You’ll reach for it rarely, for a manual “Refresh” button on a dashboard or to re-pull server data after a client-side event you know changed it.
One caveat catches almost everyone the first time, and getting it wrong causes a real production bug:
router.back() and router.forward() walk the history stack: the back and forward buttons, in code. router.prefetch(href) manually warms a route’s data and code before the user navigates; you’ll rarely need it, since <Link> already prefetches automatically on hover or when it scrolls into view. Reach for prefetch only for a known-next destination that isn’t a link, like a “next item” button or a wizard step you’re sure the user is about to hit.
useSearchParams: reading the query on the client
Section titled “useSearchParams: reading the query on the client”useSearchParams reads the current query string from inside a Client Component. Reach for it less often than you’d expect.
The server-side searchParams prop from the last lesson is the default, and it’s cheaper. useSearchParams earns its place only when a Client Component must react to the URL for its own rendering: animating a chip into an active state on the client, or keeping a local input in sync with the query as the user types. For a value the component already receives as a prop, don’t reach for the hook.
When you do need it, the read surface is small:
const searchParams = useSearchParams();
searchParams.get('status'); // string | nullsearchParams.getAll('tag'); // string[] — every ?tag= valuesearchParams.has('cursor'); // booleanThe type is ReadonlyURLSearchParams, so set and delete won’t compile: the read surface is read-only by design. Recall the repeated-key shape from the last lesson, where ?tag=billing&tag=urgent carries tag twice. getAll('tag') reads that array form; get('tag') returns only the first.
The Suspense boundary requirement
Section titled “The Suspense boundary requirement”A Client Component that calls useSearchParams must sit inside a <Suspense> boundary at a parent. Leave it out and the build fails: it forces the whole page into client-side rendering and reports a missing-Suspense error that names the component.
The reason is that Suspense is the boundary around a value that isn’t available yet. During the static prerender there’s no request, so the search params aren’t known. The component that reads them must therefore live inside a boundary, which renders a fallback into the prerendered HTML; the real query value resolves on the client once there’s an actual URL.
import { Suspense } from 'react';import { Filters } from './filters';
export default function Page() { return ( <Suspense fallback={<FiltersSkeleton />}> <Filters /> </Suspense> );}
// filters.tsx'use client';
export function Filters() { const searchParams = useSearchParams(); const status = searchParams.get('status') ?? 'all'; return <Chips active={status} />;}Filters reads the query, which isn’t known at prerender time, so it has to be a Suspense child. Delete this <Suspense> and the build fails with a missing-boundary error that names Filters.
import { Suspense } from 'react';import { Filters } from './filters';
export default function Page() { return ( <Suspense fallback={<FiltersSkeleton />}> <Filters /> </Suspense> );}
// filters.tsx'use client';
export function Filters() { const searchParams = useSearchParams(); const status = searchParams.get('status') ?? 'all'; return <Chips active={status} />;}The fallback is what the prerendered HTML shows in the gap. The real chips swap in on the client once the query resolves.
import { Suspense } from 'react';import { Filters } from './filters';
export default function Page() { return ( <Suspense fallback={<FiltersSkeleton />}> <Filters /> </Suspense> );}
// filters.tsx'use client';
export function Filters() { const searchParams = useSearchParams(); const status = searchParams.get('status') ?? 'all'; return <Chips active={status} />;}This one call triggers the rule. It’s the only reason the boundary exists here.
The cleanest fix is often not to read on the client at all. If the value is one the server already has, and status is, since the page read it from searchParams, pass it down as a prop and skip the hook entirely. The boundary is the cost of reading the URL on the client, and usually the right move is not to pay it: read on the server, and pass the prop down.
usePathname: highlighting the active nav item
Section titled “usePathname: highlighting the active nav item”usePathname does the one thing every web app needs: tell a navigation item whether it’s the active one. It returns the current path as a string, with no query and no hash, just /invoices or /settings/billing.
The canonical use is a sidebar or nav bar that highlights where the user is:
'use client';
export function NavItem({ href, label }: NavItemProps) { const pathname = usePathname(); const isActive = pathname.startsWith('/invoices');
return ( <Link href={href} aria-current={isActive ? 'page' : undefined}> {label} </Link> );}Notice startsWith, not ===. A section root like /invoices wants a prefix match, so the item stays highlighted when the user drills into a child route like /invoices/42. A single exact page wants ===. The wrong comparison is why a nav link sometimes loses its highlight the moment you open a detail view.
usePathname needs no Suspense boundary: unlike the query, the path is already known during prerender. That boundary requirement is specific to useSearchParams, not a tax on all four hooks.
useParams: dynamic segments on the client
Section titled “useParams: dynamic segments on the client”useParams returns the route’s dynamic segments as an object keyed by segment name, the same shape as the server params. For a route like /orgs/[org]/invoices/[id], you get { org: 'acme', id: '42' }.
One contrast surprises people coming from the server. The server params is a Promise you await. The client useParams() is synchronous, no await and no Promise, because by the time a Client Component runs the route has already matched in the browser. The segments are known, so there’s nothing to wait for.
const { org } = useParams(); // synchronous — the route already matched// (on the server: const { org } = await params;)But should you reach for it? The case is a Client Component buried deep in the tree that needs the org slug without threading it through a dozen intermediate props. Prefer passing the value down as a prop when the tree is shallow, and reach for useParams only when prop-drilling depth makes it painful. The hook isn’t the default; it’s the escape hatch for when the prop path costs too much.
Putting it together: the filter-chip list
Section titled “Putting it together: the filter-chip list”This is the StatusFilter component the page rendered in the very first comparison, the one that completes the previous lesson’s invoice page.
'use client';
const STATUSES = ['draft', 'paid', 'overdue'] as const;
export function StatusFilter({ current }: { current: string }) { const router = useRouter(); const searchParams = useSearchParams();
const selectStatus = (status: string) => { const params = new URLSearchParams(searchParams.toString()); params.set('status', status); router.replace(`?${params.toString()}`, { scroll: false }); };
return ( <div role="group" aria-label="Filter by status"> {STATUSES.map((status) => ( <button key={status} aria-pressed={status === current} onClick={() => selectStatus(status)} > {status} </button> ))} </div> );}The active value arrives as a prop: the page read it from searchParams on the server and passed it down. The chip is told what’s active; it doesn’t go looking.
'use client';
const STATUSES = ['draft', 'paid', 'overdue'] as const;
export function StatusFilter({ current }: { current: string }) { const router = useRouter(); const searchParams = useSearchParams();
const selectStatus = (status: string) => { const params = new URLSearchParams(searchParams.toString()); params.set('status', status); router.replace(`?${params.toString()}`, { scroll: false }); };
return ( <div role="group" aria-label="Filter by status"> {STATUSES.map((status) => ( <button key={status} aria-pressed={status === current} onClick={() => selectStatus(status)} > {status} </button> ))} </div> );}The one navigation hook this component needs. useRouter is here to write, nothing else.
'use client';
const STATUSES = ['draft', 'paid', 'overdue'] as const;
export function StatusFilter({ current }: { current: string }) { const router = useRouter(); const searchParams = useSearchParams();
const selectStatus = (status: string) => { const params = new URLSearchParams(searchParams.toString()); params.set('status', status); router.replace(`?${params.toString()}`, { scroll: false }); };
return ( <div role="group" aria-label="Filter by status"> {STATUSES.map((status) => ( <button key={status} aria-pressed={status === current} onClick={() => selectStatus(status)} > {status} </button> ))} </div> );}On click, the handler builds the next query string, then calls replace so filters don’t pile up in history, with scroll: false so the viewport holds still. The next section covers the query-string construction; note for now that it starts from the current params, not from scratch.
'use client';
const STATUSES = ['draft', 'paid', 'overdue'] as const;
export function StatusFilter({ current }: { current: string }) { const router = useRouter(); const searchParams = useSearchParams();
const selectStatus = (status: string) => { const params = new URLSearchParams(searchParams.toString()); params.set('status', status); router.replace(`?${params.toString()}`, { scroll: false }); };
return ( <div role="group" aria-label="Filter by status"> {STATUSES.map((status) => ( <button key={status} aria-pressed={status === current} onClick={() => selectStatus(status)} > {status} </button> ))} </div> );}The active state is a plain comparison against the current prop, no useSearchParams. The server already told us which filter is active, so reading it again on the client would be redundant work and an extra Suspense boundary.
The only thing this client component does is write the URL. The active state is server-derived: the page re-renders on the server with the new status, runs the database query, and streams back the filtered table. Both halves of the chapter sit in one small file: the server is a pure function of the URL, and the client’s only job is to change the URL.
Building the query string without losing other params
Section titled “Building the query string without losing other params”There’s a real trap hiding in that click handler. The tempting way to “set the status filter” is to write the URL by hand:
router.replace('?status=paid'); // wipes ?sort, ?cursor, every other paramThat string is the entire query now. If the user had sorted by date and paged forward, ?sort=-date&cursor=... just vanished: one click throws away the rest of their view state. The fix is to merge, not overwrite. Seed a fresh URLSearchParams from the current query, set the one key you’re changing, and serialize the result.
const params = new URLSearchParams(searchParams.toString());params.set('status', value);router.replace(`?${params.toString()}`, { scroll: false });This is where useSearchParams earns its place. Not to read the active state, which is the prop, but because the handler needs the existing query to preserve it while changing one key.
The two rules look like they contradict, but they don’t: the chip reads its active state from a prop (no hook needed), and the handler uses useSearchParams to merge the existing query when it writes. Different jobs, different tools, one component. In a real project you’d lift the merge into a small helper in _lib/, something like withParam(searchParams, key, value) that returns the new query string, so every filter and sort control shares one correct implementation instead of re-deriving the merge and occasionally getting it wrong.
The StatusFilter below is wired to an onNavigate(href) callback that stands in for router.replace — the iframe has no Next.js router, so onNavigate just records the URL it was called with. Two things are missing. First: selectStatus must build the next query string by merging into the current params (the query prop, a string like 'sort=-date') — set status to the clicked value and preserve everything else — then call onNavigate with '?' + the serialized string. Second: each chip's aria-pressed must be derived from the current prop. Don't read the active state any other way.
Reference solution
const selectStatus = (status: string) => { const params = new URLSearchParams(query); params.set('status', status); onNavigate(`?${params.toString()}`);};<button key={status} aria-pressed={status === current} onClick={() => selectStatus(status)}>new URLSearchParams(query) seeds the params from the existing query, so params.set('status', …) overwrites only that key and sort=-date survives the serialize. The active state is a plain status === current comparison against the prop the page passed down, no useSearchParams and no Suspense boundary. In the real component the seed comes from searchParams.toString() instead of a query prop, and onNavigate is router.replace(href, { scroll: false }).
What these hooks don’t do, and where nuqs takes over
Section titled “What these hooks don’t do, and where nuqs takes over”These four hooks touch one input of a route, the URL, and not the other two. They don’t read cookies or headers; those are server-only reads from the first lesson of this chapter, with no client hook by design. They don’t call Server Actions; you call those directly, not through the router. And they don’t fetch arbitrary URLs; fetch is still the tool for talking to an API. Their remit is the URL alone: read it, navigate to a new one, refresh the current one.
Once you’ve built the chip handler by hand (parse the current params, set one key, serialize, replace with scroll: false, derive active state from a prop), you’ve essentially written the inside of nuqs, the production layer for URL state. nuqs collapses all of that into one typed hook:
const [status, setStatus] = useQueryState( 'status', parseAsStringEnum(['draft', 'paid', 'overdue']),);// setStatus('paid') writes the URL (useSearchParams + router.replace) and re-rendersIt returns a [value, setValue] pair like useState, except setValue writes the URL (wrapping the same useSearchParams and router.replace you just used) and the value is typed and parsed instead of a raw string. The threshold is the one from the last lesson: for a single filter the bare hooks are fine, but once you have two or three URL-state controls, the hand-written parse-merge-serialize code repeats and drifts out of sync, and nuqs pays for itself. It’s the canonical production pick, and you’ll build a list with it later in the course.
External resources
Section titled “External resources”Official Next.js reference for the four navigation hooks, including the full router method list.
How soft navigation, prefetching, and client-side transitions work — the machinery behind router.push and replace.
The web API behind the merge trick: get, getAll, has, set, delete — the read methods the hook reuses.
The production layer these bare hooks sit underneath. Skim now; you'll use it later in the course.