Skip to content
Chapter 33Lesson 5

Client-side navigation hooks

The four next/navigation hooks a Client Component uses to read the current URL and navigate to a new one, and the discipline of reading URL state on the server while only writing it from the client.

Back on the invoice list, picture the one interaction the last lesson kept promising 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 without the page flashing white. The table re-renders showing only paid invoices. Then they hit the back button, and Draft comes back.

You already built the entire server half of that in the previous lesson on URL state. The page reads searchParams, validates them, queries the database, and renders the filtered table. The same URL in produces the same page out. The phrase that anchored it was the server is a pure function of the URL.

But that page only reads the URL. It never changes it. So who clicks the chip, and who turns that click into ?status=paid? That is the missing half, and it is the job of four hooks from next/navigation: useRouter, usePathname, useSearchParams, and useParams.

One division of labor runs through the whole lesson, so it’s worth stating before any syntax: read on the server, write on the client. The server reads the URL and renders the page. The client’s only job is to change the URL. Those two sentences are mirror images, and together they are the mental model this lesson is built around. By the end you’ll write the chip handler that completes the previous lesson’s invoice page, and the surprising part is how little of it touches a hook at all.

Before drilling into any one of them, here is the whole toolbox in one place. This is the only time all four appear together, so treat it as a map, not a tutorial.

import {
useRouter,
usePathname,
useSearchParams,
useParams,
} from 'next/navigation';
const router = useRouter(); // { push, replace, back, forward, refresh, prefetch } — the write hook
const pathname = usePathname(); // current path as a string, no query, no hash
const searchParams = useSearchParams(); // ReadonlyURLSearchParams for the current query
const params = useParams(); // the route's dynamic segments as an object

One hook writes; three read. useRouter is the only one that navigates. The other three just tell you what’s currently in the URL.

One rule applies to all four before anything else: they are Client Component hooks. Call any of them from a Server Component and your build fails, because they need 'use client' at the top of the file. This isn’t a new rule so much as the module-boundary rule you already know: interactivity lives at the smallest leaf that needs it, and these hooks are as interactive as it gets, since they move the user to a new URL.

That last hook, useSearchParams, returns a ReadonlyURLSearchParams . This is the same URLSearchParams you met on the web platform earlier in the course, with get, getAll, and has all working the same way, but with one difference baked into the type: you can’t set or delete on it. You’ll see why that matters, and how to work around it, 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”

The rest of the lesson builds on this section, so it’s worth going slowly here.

If you’re coming from a single-page-app background, your reflex when something needs to be interactive is to pull everything to the client. You read searchParams on the client, derive the filtered data in a useEffect, and hold the result in useState. That instinct quietly rebuilds the exact 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 doesn’t need these 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.

Look at the two shapes side by side. They produce the same filtered list on screen, but they are not the same thing.

invoice-list.tsx
'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 turn around and ask the server for data it could have rendered directly.

The left version feels natural and is wrong. The right version is the one you want, and it has a shape worth naming: the client surface is the smallest leaf that initiates the navigation. Everything above it stays on the server.

This extends a reflex you’ve been building since the start of this part of the course: read high, pass resolved values down. The page reads the URL high up and passes the resolved value, the current status, down as a prop. The easy thing to miss is that the values you pass down include the current filter. So the chip can read its own active state from a prop. It doesn’t need a hook to find out which filter is active, because the server already told it. Keep that in mind, because the final section of the lesson depends on it.

useRouter is the write hook, the only one of the four that actually 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 . Here is what it does, 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 the viewport to the top. Think of it as the programmatic version of clicking a <Link>, which you already know from the routing chapter. It’s the same soft navigation and the same prefetching, just triggered from your code instead of a click on an anchor.

That scroll-to-top default is the right behavior when you’re genuinely moving to a new page. It’s the wrong behavior 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 });

Keep scroll: false in mind: it shows up in the chip handler at the end of the lesson, and forgetting it is the difference between a filter that feels native and one that jerks the page around.

push vs replace: what the back button should do

Section titled “push vs replace: what the back button should do”

useRouter gives you two ways to navigate, and reaching for push everywhere is one of the most common mistakes beginners make. The mechanical difference is one line:

  • push adds a new history entry on top of the stack.
  • replace swaps the current entry: the same URL change, but no new frame.

The decision isn’t really about mechanics, though. It’s about what the user expects the back button to do, which is the same kind of question the last lesson used to decide URL state versus component state. Framed that way, the two cases split cleanly:

  • Use replace for filter, sort, and pagination changes. The user toggling through five filters shouldn’t have to press back five times to escape the page. They expect “back” to leave the list, not rewind their last chip click.
  • Use push for genuine navigation between distinct views, like opening an invoice or moving to a settings page. Each of those 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.

The rule to keep: if the user wouldn’t think of it as “a place I navigated to,” use replace.

Let’s pin that down before moving on.

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.

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 tells the router to re-fetch and re-render the current route’s Server Components, leaving the URL exactly where it is. You’ll reach for it rarely: a manual “Refresh” button on a dashboard, or re-pulling 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:

The rest of the router surface needs only a line each, since there’s no real decision to make with them. 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, because <Link> already prefetches automatically when it scrolls into view or on hover. 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 certain 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. The first thing to get right is when you should, and the honest answer is less often than you’d think.

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 needs to 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. (Yes, this is the chip again. We’re almost there.)

When you do need it, the read surface is small:

const searchParams = useSearchParams();
searchParams.get('status'); // string | null
searchParams.getAll('tag'); // string[] — every ?tag= value
searchParams.has('cursor'); // boolean

The type is ReadonlyURLSearchParams, so set and delete won’t compile: the read surface is read-only on purpose. And remember the repeated-key shape from the last lesson, where a URL like ?tag=billing&tag=urgent carries tag twice. getAll('tag') is how you read that array form on the client; get('tag') would hand you only the first.

This is the one rule in the lesson that breaks your build with a specific error, so it’s worth understanding rather than memorizing. A Client Component that calls useSearchParams must sit inside a <Suspense> boundary at a parent. Leave the boundary 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 the same Suspense model you’ve been using since the streaming chapters, where Suspense is the boundary around something that isn’t available yet. During the static prerender, the search params aren’t known: there’s no request yet, so there’s no query to read. The component that reads them therefore has to live inside a Suspense boundary. The boundary renders a fallback into the prerendered HTML, and 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} />;
}

The boundary is the whole point. 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 is what triggers the rule. It’s the only reason the boundary exists here, and the next paragraph asks whether that cost is worth paying.

1 / 1

There’s a reframe that dissolves most of this tension. The cleanest fix is often not to read on the client at all. If the value is something the server already has, and status is, since the page read it from searchParams, then pass it down as a prop and skip the hook entirely. No hook, no boundary, no fallback. 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 has one job that every SaaS app needs: telling 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 currently is:

nav-item.tsx
'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 nav item stays highlighted when the user drills into a child route like /invoices/42. A single exact page wants ===. Picking the wrong comparison is why a nav link sometimes loses its highlight the moment you open a detail view.

One more thing is worth noticing, because it sharpens the Suspense rule from the last section: usePathname is fast, side-effect-free, and needs no Suspense boundary. The path is known during prerender in a way the query isn’t, which is exactly why the boundary requirement is specific to useSearchParams and not a tax on all four hooks.

useParams rounds out the toolbox: it returns the route’s dynamic segments as an object, the same shape as the server params, keyed by segment name. For a route like /orgs/[org]/invoices/[id], you get { org: 'acme', id: '42' }.

One contrast is worth pinning, because it surprises people coming from the server side. The server params is a Promise you await (Next.js made it async). The client useParams() is synchronous, with no await and no Promise. The reason is simple once you see it: by the time a Client Component runs, the route has already matched in the browser. The segments are already 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 for it is a Client Component buried deep in the tree that needs the org slug without threading it through a dozen intermediate props. The rule here is the same one from the chip discussion: prefer passing the value down as a prop when the tree is shallow, and reach for useParams only when prop-drilling depth makes it genuinely painful. That’s the same judgment you made with useSearchParams versus the prop, which tells you it generalizes. The hooks aren’t the default; they’re the escape hatch for when the prop path costs too much.

Everything converges here. This is the StatusFilter component the page in the very first comparison rendered, the one that completes the previous lesson’s invoice page. Watch what it does and, more importantly, what it doesn’t.

'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 actually 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 the filter doesn’t pile up in history, with scroll: false so the viewport holds still. The query-string construction is the next section; 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 derived from the current prop with a plain comparison, no useSearchParams. The server already told us which filter is active, so reading it again on the client would be redundant work and an unnecessary Suspense boundary.

1 / 1

Read the whole component back and the shape is clear: 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. Here 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, and it’s worth isolating. The tempting version of “set the status filter” is to write the URL by hand:

router.replace('?status=paid'); // wipes ?sort, ?cursor, every other param

That string is the entire query now. If the user had also sorted by date and paged forward, ?sort=-date&cursor=... just vanished: one click silently throws away the rest of their view state. The fix is to merge, not overwrite. Start 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 });

And here is where useSearchParams finally earns its place in this component. Not to read the active state, which is the prop, but because the handler needs the existing query to preserve it while it changes one key. That’s the “react to URL changes” case from earlier, now concrete.

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, same component. In a real project you’d lift the merge into a tiny helper in _lib/, something like withParam(searchParams, key, value) that returns the new query string. Then every filter and sort control shares one correct implementation instead of re-deriving the merge and occasionally getting it wrong.

Now you’ll write the load-bearing parts yourself.

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.

Preview
    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 one key and sort=-date survives the serialize. The active state is a plain status === current comparison against the prop the page passed down, with no useSearchParams and no Suspense boundary. In the real component the seed comes from searchParams.toString() instead of a query string prop, and onNavigate is router.replace(href, { scroll: false }).

    Where these hooks stop, and where nuqs begins

    Section titled “Where these hooks stop, and where nuqs begins”

    Four hooks is a small surface, which makes it tempting to assume they do more than they do. They don’t. Naming the edges is worth doing, because those edges trace the chapter’s mental model: a route’s inputs are the URL, the headers, and the cookies, and these hooks touch exactly one of those three.

    So, plainly, here is what these hooks do not do:

    • They don’t read cookies or headers. Those are server-only reads, from the first lesson of this chapter, and there is no client hook for them by design.
    • They don’t call Server Actions. You call those directly, not through the router (the full wiring is a later chapter).
    • They don’t fetch arbitrary URLs. fetch is still the tool for talking to an API.

    They are scoped to the router’s one job, the URL: read what’s in it, navigate to a new one, refresh the current one. That’s the whole remit.

    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-renders

    It 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 and nuqs is overkill. Once you have two or three URL-state controls, the hand-written parse-merge-serialize code repeats across them and tends to drift 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; the API isn’t the point here.

    What’s worth holding onto is the symmetry, because nuqs mirrors it too: it reads on the server (the createSearchParamsCache parser from the last lesson) and writes on the client (this hook). It packages the same division of labor this whole lesson taught. The server reads the URL and renders; the client changes the URL. These four hooks are the client’s half, and having now written both halves by hand, you can see exactly what the production library does for you.