skip to content

In a Next.js App Router Client Component, how do you read the current pathname and query string, and why can a build fail with "useSearchParams() should be wrapped in a suspense boundary"?

level: middleimportance: should knowfreq 55%

answer

  1. both live in next/navigation
  2. one returns a string, one a URLSearchParams
  3. the query is not known at build time
  4. the boundary bounds what falls back to the client
  5. tight boundary, not the whole page

basics

~20 s

Use usePathname() and useSearchParams() from next/navigation inside a client component. useSearchParams reads a value known only at request time, so on a statically prerendered route Next requires a Suspense boundary around the component that calls it.

solid answer

~50 s

Both hooks come from `next/navigation` and only work in Client Components. `usePathname()` returns the path portion of the URL as a string, with no query attached; `useSearchParams()` returns a read-only `URLSearchParams`, so you read values with `.get('sort')` and serialize with `.toString()`. The build error appears because the query string is not known at build time: when a route is statically prerendered, a client component that calls `useSearchParams()` cannot be prerendered with it, so Next makes the client tree up to the nearest Suspense boundary render on the client — and if there is no boundary, that is the whole page, which it refuses to do silently. The fix is to wrap the component that reads the params in `<Suspense fallback={…}>` so the rest of the page still prerenders. Note also that these hooks read the URL; to change it you go through `router.push` or `router.replace`.

code

tsx · 22 lines
tsx
'use client';

import { usePathname, useRouter, useSearchParams } from 'next/navigation';

export function SortSelect() {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const router = useRouter();

  function setSort(value: string) {
    const params = new URLSearchParams(searchParams.toString());
    params.set('sort', value);
    router.replace(`${pathname}?${params.toString()}`);
  }

  return (
    <select value={searchParams.get('sort') ?? 'name'} onChange={(e) => setSort(e.target.value)}>
      <option value="name">Name</option>
      <option value="price">Price</option>
    </select>
  );
}

go deeper

for a junior

Know that usePathname() and useSearchParams() come from next/navigation, need 'use client', and are read-only — changing the URL means navigating with the router.

for a middle

Explain why the Suspense boundary is required: the query string exists only per request, so on a prerendered route the subtree that reads it is deferred to the client and the boundary marks how far that deferral reaches.

for a senior

Show that you place the boundary deliberately. Point out that a page-level boundary trades away prerendering, and question whether the value needs to be read client-side at all when it drives server-rendered content.

for a principal

Own the URL-as-state convention across the app: which state belongs in the query string, who writes it, and how filter UIs avoid both history spam and accidental whole-page client rendering.

## The two hooks `next/navigation` exposes a small set of read-only hooks for Client Components: ```tsx 'use client'; import { usePathname, useSearchParams } from 'next/navigation'; export function SortLabel() { const pathname = usePathname(); // '/products' const searchParams = useSearchParams(); // URLSearchParams for '?sort=price' const sort = searchParams.get('sort') ?? 'default'; return <p>{pathname} sorted by {sort}</p>; } ``` - **`usePathname()`** returns a string: the pathname only. `/products?sort=price#top` gives you `/products`. No query, no hash. - **`useSearchParams()`** returns a **read-only `URLSearchParams`** instance. The standard reading methods are there — `get`, `getAll`, `has`, `keys`, `entries`, `toString` — but the mutating ones do not modify the URL. To change the query you build a new `URLSearchParams` and navigate. Both require `'use client'` in the module. They are hooks; there is no server equivalent of them, and a Server Component receives what it needs about the URL through its props instead. ## Why the Suspense requirement exists A statically prerendered route is rendered once, at build time, and served to every visitor. At that moment the query string does not exist — `?sort=price` is a property of a request that has not happened yet. So any component that calls `useSearchParams()` has nothing truthful to render during prerendering. Next's resolution is to **defer that subtree to the client**. The component that reads the params, and everything up to the nearest `<Suspense>` boundary above it, is rendered in the browser instead of being baked into the static HTML. The boundary is what tells Next where the deferred region stops. If there is no boundary, the deferred region has no upper bound — it swallows the entire page, and the static prerender you were trying to produce becomes worthless. Rather than doing that quietly, the build fails with a message naming the offending page: > useSearchParams() should be wrapped in a suspense boundary at page "/products" ## The fix Put the reader in its own component and wrap it: ```tsx import { Suspense } from 'react'; import { SortLabel } from './sort-label'; export default function Page() { return ( <main> <h1>Products</h1> {/* still prerendered */} <Suspense fallback={null}> <SortLabel /> {/* client-rendered */} </Suspense> </main> ); } ``` Two design consequences follow, and they are the interesting half of this question: 1. **Keep the boundary tight.** Wrapping the whole page in Suspense makes the error disappear and makes the page entirely client-rendered — the error is "fixed" and the benefit is lost. Isolate the smallest component that genuinely needs the query. 2. **Ask whether the query needs to be read on the client at all.** If the value drives server-rendered content, the server already has a way to receive it, and pulling it into a client component is a detour that costs you prerendering. ## Writing back to the URL These hooks read. To change the query you construct a new string and navigate: ```tsx const params = new URLSearchParams(searchParams.toString()); params.set('sort', 'price'); router.replace(`${pathname}?${params.toString()}`); ``` Copy through `toString()` rather than mutating the returned object: the instance you get back is read-only and shared. `replace` rather than `push` is usually right for filter and sort controls, so each adjustment does not add a back-button entry. ## Related hooks and common mistakes `next/navigation` also exports `useParams()` for the current route's dynamic parameters, and `useSelectedLayoutSegment()` / `useSelectedLayoutSegments()`, which are how a layout highlights the active child — a nav that compares `usePathname()` against every href with string equality is usually reinventing that, and gets nested routes and trailing slashes wrong. The other classic mistake is importing `useRouter` from `next/router`. That is the Pages Router hook; in the App Router it throws "NextRouter was not mounted". All of these App Router hooks live in `next/navigation`.

  • Why is it a poor fix to wrap the entire page in Suspense to silence that build error?
    Because the boundary defines how much of the tree is deferred to the client. A page-level boundary means the whole page is client-rendered — the build passes and every prerendering benefit is gone. Wrap only the small component that reads the query so the rest of the page still ships as static HTML.
  • How do you update a single query parameter without dropping the others?
    Copy the current params into a fresh instance — `new URLSearchParams(searchParams.toString())` — set the one key, then navigate to `` `${pathname}?${params.toString()}` ``. The object returned by `useSearchParams()` is read-only, so mutating it in place does nothing, and rebuilding the string from scratch silently drops parameters other controls own.
  • A nav component highlights the active link by comparing usePathname() to each href. What is the better tool?
    `useSelectedLayoutSegment()` from `next/navigation`, which tells a layout which of its child segments is active. String-comparing the full pathname breaks on nested routes, trailing slashes, and route groups, and it forces every nav item to know the full URL shape rather than just its own segment.

saying these in an interview costs you the question

  • Imports useRouter from next/router in the App Router
  • Thinks usePathname() includes the query string
  • Tries to mutate the object useSearchParams() returns
  • Wraps the whole page in Suspense to silence the error
  • Calls these hooks in a Server Component

context