cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

Search params and URL state

Keep search, filters and pages in the URL with Next.js: the searchParams prop, useSearchParams and its Suspense, router.replace, and Link with a query.

By the end, you can
  • 01
    Read the query string on the serverAwait the searchParams prop inside Suspense, and handle values that are missing, repeated or not numbers.
  • 02
    Read and change it in the browserUse useSearchParams with the Suspense it needs, and update the address with router.replace or a Link with a query.
  • 03
    Choose where state livesDecide what belongs in the URL, so it can be shared and bookmarked, and what stays in a component.

One page, a different list for each address

In Dynamic routes, the address picked which tea a page showed: /teas/green. Now the shop wants a search. A visitor types green, and the list shows only the green teas. Where does the word green go? The usual answer on the web is the address itself: /teas?q=green.

The part after the ? is the query string. It holds pairs of a name and a value, written name=value and joined by &: ?q=green&page=2. Each pair is a search param. Next.js gives a page all of them in a prop called searchParams. Here is the shop's list of teas, which reads one search param, q. Like every example in this lesson, it's a real Next.js 16.4.3 project, built with next build and run with next start, and what it sent back was recorded. Open the Browser tab and pick each address above the page.

The teas page reads q from the addressNext.js
app/teas/
app/teas/page.tsx
import { Suspense } from "react";import { findTeas } from "./data"; export default function TeasPage({ searchParams }: PageProps<"/teas">) {  return (    <main>      <h1>Our teas</h1>      <Suspense fallback={<p>Loading teas…</p>}>        <TeaList searchParams={searchParams} />      </Suspense>    </main>  );} async function TeaList({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) {  const { q } = await searchParams;  const teas = findTeas(typeof q === "string" ? q : "");   if (teas.length === 0) return <p>No teas match.</p>;  return (    <ul>      {teas.map((tea) => (        <li key={tea.slug}>{tea.name}</li>      ))}    </ul>  );}
Recorded from Next.js 16.4.3
localhost:3000/teasTea shop
Home
Our teas
  • Green tea
  • Mint tea
  • Oolong tea
  • Jasmine green tea
  • Black tea
  • Chamomile

The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.

One file, app/teas/page.tsx, answers all four addresses. Line by line:

  • Line 4: Next.js calls TeasPage with a searchParams prop. Like params, it's a promise.
  • Lines 8 to 10 put TeaList inside Suspense, with Loading teas… as the fallback, so the build has something to show while the list waits for the query string.
  • Line 16 waits for searchParams and takes q out of it. Line 17 keeps the teas whose name contains q, with findTeas from app/teas/data.ts. A name can be given twice, so q can be an array; line 17 only searches when it's one string, and shows every tea otherwise.
  • Line 19 shows No teas match. when nothing is left.

The Browser tab shows six teas for /teas, Green tea and Jasmine green tea for /teas?q=green, Mint tea for /teas?q=mint, and No teas match. for /teas?q=coffee. The Terminal lists one route, ◐ /teas: the legend says (Partial Prerender) prerendered as static HTML with dynamic server-streamed content. The heading and the fallback were made once, during the build, as the page's shell. The list was made for each request, from its query string. The Network tab shows both in each response's HTML: Loading teas… first, then the list.

The address slash teas question mark q equals mint and page equals 2, in three cards: slash teas, the path; q equals mint and page equals 2, the query string. An arrow goes from the path to an editor window, app/teas, holding page.tsx. Arrows go from the two pairs to one card: q mint, page 2, labeled searchParams.the paththe query string/teas?q=mint&page=2app/teaspage.tsx{ q: "mint", page: "2" }searchParams
/teas?q=mint&page=2 in two parts. The path, /teas, picks the page, as folders always do. The query string after the ? is pairs joined by &, and the page gets them as one object, searchParams.
Real-life analogyA slip at the library desk

At a library desk, you write a slip: the shelf, and under it a note about which books you want. The librarian reads the whole slip and brings exactly those books. Hand a friend a copy of the slip, and they get the same books.

In real life: The shelf the slip names: Teas
In Next.js: The path, /teas, which picks the page
In real life: The notes under it: only green ones, the second dozen
In Next.js: The query string, ?q=green&page=2
In real life: The librarian reading the slip before fetching the books
In Next.js: The page reading searchParams on the server
In real life: A copy of the slip you hand a friend
In Next.js: A shared link: the same address shows the same list
The main idea

The query string is state you keep in the address. The path picks the page; the search params tell it what to show. A page reads them in the searchParams prop, so the server can make the right HTML, and a visitor can share, bookmark or reload the address and see the same thing.

The searchParams prop

What does the object look like? This page prints the searchParams it gets, as text, with JSON.stringify on line 15. The recorder asked it for seven addresses. Pick each one above the page.

A page that prints its searchParamsNext.js
app/teas/
app/teas/page.tsx
import { Suspense } from "react"; export default function TeasPage({ searchParams }: PageProps<"/teas">) {  return (    <main>      <h1>Our teas</h1>      <Suspense fallback={<p>Loading…</p>}>        <ShowSearchParams searchParams={searchParams} />      </Suspense>    </main>  );} async function ShowSearchParams({ searchParams }: { searchParams: Promise<object> }) {  return <p>searchParams: {JSON.stringify(await searchParams)}</p>;}
Recorded from Next.js 16.4.3
localhost:3000/teasTea shop
Home
Our teas

searchParams: {}

The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.

Each row is one address from that recording, and the object the page printed for it:

Query strings and searchParams
AddressWhat the page gotWhat to notice
/teas{}No query string: an empty object.
/teas?q=mint{"q":"mint"}One key, one string.
/teas?q=mint&page=2{"q":"mint","page":"2"}& separates pairs. "2" is a string.
/teas?q=mint&q=green{"q":["mint","green"]}A key given twice becomes an array.
/teas?q={"q":""}A key with no value is an empty string.
/teas?q=green%20tea{"q":"green tea"}%20 is a space, decoded for you.

So, when you read a search param:

  • It may be missing. /teas gave {}, so q is undefined. Give it a default.
  • It's a string. page is "2", not 2. Turn it into a number with Number(page) before you count with it.
  • It may be an array. ?q=mint&q=green gave ["mint","green"]. Check with typeof q === "string", as line 17 of the teas page does.
  • It's a plain object, not the browser's URLSearchParams: read q with destructuring, not .get("q").

Like params, searchParams is a promise, because the query string isn't known during the build: the server receives it with each request. With params, a shop can list its teas ahead of time. A query string can be anything a visitor types, so it can't be listed: the part that reads it waits inside Suspense. Pick each version, and read the Terminal.

Playground: where can the page wait for searchParams?
The tea shop, with this version of app/teas/page.tsxNext.js
app/teas/
app/teas/page.tsx
import { findTeas } from "./data"; export default async function TeasPage({ searchParams }: PageProps<"/teas">) {  const { q } = await searchParams;  const teas = findTeas(typeof q === "string" ? q : "");   return (    <main>      <h1>Our teas</h1>      <ul>        {teas.map((tea) => (          <li key={tea.slug}>{tea.name}</li>        ))}      </ul>    </main>  );}
next build failed · Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...Error: Route "/teas": Next.js encountered uncached or runtime data during prerendering. `fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience. Ways to fix this:  - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access  - [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)  - [block] Set `export const instant = false` to allow a blocking route Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic    at body (<anonymous>)    at html (<anonymous>)To get a more detailed stack trace and pinpoint the issue, try one of the following:  - Start the app in development mode by running `next dev`, then open "/teas" in your browser to investigate the error.  - Rerun the production build with `next build --debug-prerender` to generate better stack traces.Error occurred prerendering page "/teas". Read more: https://nextjs.org/docs/messages/prerender-errorExport encountered an error on /teas/page: /teas, exiting the build.⨯ Next.js build worker exited with code: 1 and signal: null
Try it yourself
await at the top

Line 4 waits for searchParams before anything is shown. During the build there's no request, so no query string, and nothing to show meanwhile. Next.js stops the build and names the fixes.

Both versions were built with next build from Next.js 16.4.3; the Terminal shows what it printed. Only app/teas/page.tsx changes. Reset goes back to the first version.

Awaited at the top, the build stops: Route "/teas": Next.js encountered uncached or runtime data during prerendering. Awaited inside Suspense, it builds as ◐ /teas. The more of the page that sits outside the Suspense, the more of it is ready in the shell: here, the nav and the heading. The Rendering and caching stage covers request-time data in full.

Layouts don't get searchParams

Only pages get the searchParams prop. Next.js's docs explain why: a layout stays on screen while the visitor moves between the pages inside it, and isn't made again, so it would keep showing old values. A layout that needs the query string uses useSearchParams in a Client Component, next.

useSearchParams and the Suspense it needs

The shop wants a search box that shows what was searched for. A box reacts to typing, so it's a Client Component. Pages start as Server Components, which run only on the server; a file that starts with "use client" marks where the client part begins, and its components, and what it imports, also run in the browser. A Client Component reads the query string with the useSearchParams hook:

app/teas/search-box.tsx: a box that starts with the searchTSX
"use client";

import { useSearchParams } from "next/navigation";

export default function SearchBox() {
  const searchParams = useSearchParams();

  return <input name="q" placeholder="Search teas" defaultValue={searchParams.get("q") ?? ""} />;
}

This file is part of the recorded projects below; it runs there, not in this page. Line 3 imports useSearchParams from next/navigation. Line 6 calls it. It returns a read-only URLSearchParams, the browser's own object for a query string, so line 8 reads q with .get("q"): "mint" on /teas?q=mint, or null without one, which ?? "" turns into an empty box.

During the build, there is no query string to give the hook. So, like await searchParams, the component needs a Suspense around it, with something to show meanwhile. Each button is one version of the teas page with this box. Before you click, guess what each build does, and what the HTML for /teas?q=mint holds.

Playground: who fills the search box?
The tea shop, with this version of app/teas/page.tsxNext.js
app/teas/
app/teas/page.tsx
import SearchBox from "./search-box"; export default function TeasPage() {  return (    <main>      <h1>Our teas</h1>      <SearchBox />    </main>  );}
next build failed · Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...Error: Route "/teas": Next.js encountered URL data `useSearchParams()` in a Client Component outside of `<Suspense>`. This blocks prerendering because the value is only available at runtime. Ways to fix this:  - [stream] Wrap the component in `<Suspense fallback={...}>` so the hook value streams in after prerendering  - [block] Set `export const instant = false` to allow a blocking route Learn more: https://nextjs.org/docs/messages/blocking-prerender-client-hook    at <unknown> (app/teas/search-box.tsx:6:24)    at main (<anonymous>)    at body (<anonymous>)    at html (<anonymous>)  4 |  5 | export default function SearchBox() {> 6 |   const searchParams = useSearchParams();    |                        ^  7 |  8 |   return <input name="q" placeholder="Search teas" defaultValue={searchParams.get("q") ?? ""} />;  9 | } {  digest: 'CLIENT_HOOK_DYNAMIC'}To get a more detailed stack trace and pinpoint the issue, try one of the following:  - Start the app in development mode by running `next dev`, then open "/teas" in your browser to investigate the error.  - Rerun the production build with `next build --debug-prerender` to generate better stack traces.Error occurred prerendering page "/teas". Read more: https://nextjs.org/docs/messages/prerender-errorExport encountered an error on /teas/page: /teas, exiting the build.⨯ Next.js build worker exited with code: 1 and signal: null
Try it yourself
No Suspense

The box on line 7 reads the query string, and nothing around it says what to show without one. Next.js stops the build at line 6 of search-box.tsx: URL data useSearchParams() in a Client Component outside of <Suspense>.

Every version was built with next build from Next.js 16.4.3 and run with next start, which was asked for /teas?q=mint; the ○ version was also opened in real Chrome. Only app/teas/page.tsx changes. Reset goes back to the first version.

Without Suspense, the build stops at line 6 of search-box.tsx: Next.js encountered URL data useSearchParams() in a Client Component outside of <Suspense>. With it, what happens depends on the rest of the page:

  • Nothing else reads the request. The page is the same for everyone, so the build makes it once: ○ /teas, (Static) prerendered as static content. Its HTML holds the fallback, Loading the search box…, for every query string. Open the Steps tab: in real Chrome, once the page's JavaScript ran, the box showed mint. The browser drew it.
  • The list reads searchParams. The page is now made for each request, ◐ /teas, and the server renders the box along with it. The Network tab shows value="mint" in the HTML.
Two halves. Left, circle slash teas: a window for the HTML holds the fallback, Loading the search box, and an arrow goes down to a window, in Chrome, where the box shows value mint. Right, half circle slash teas: one window for the HTML already holds the box with value mint, and the line Mint tea.○ /teasthe HTMLLoading the search box…in Chromevalue="mint"drawn by the browser◐ /teasthe HTMLvalue="mint"Mint teamade by the server,for this request
Two pages, both asked for /teas?q=mint. Left, nothing else reads the request: the page is ○, its HTML holds the fallback, and the browser draws the box. Right, the list reads searchParams: the page is ◐, and the server makes the box with value="mint" for this request.

Each row is one version: what the build does, and what the HTML for /teas?q=mint holds.

The search box, three ways
The pageWhat next build doesWhat the HTML holds
The box outside SuspenseStops with an error that names the box's line.Nothing: there's no build.
In Suspense, nothing else reads the request○ /teas: one page for every query string.The fallback. The box is drawn in the browser.
In Suspense, the list reads searchParams◐ /teas: a shell, then the rest for each request.The box, with value="mint", made on the server.

Which to use? In a page, read the searchParams prop: the server can use the values to load data, and pass them to any component as props. In a Client Component deep in the page, or one a layout uses, call useSearchParams, and put a Suspense as close around it as you can, so the rest stays in the shell.

Updating the URL without a reload

Reading the address is half of it. The box should also change the address as the visitor types, so the list follows. Here is the shop with that box, two teas a page, and links to the other pages. The file shown first is the box. Read it, then open the Steps tab.

A search box that changes the addressNext.js
app/teas/
app/teas/tea-list.tsx
import Link from "next/link";import { findTeas } from "./data"; const perPage = 2; export default async function TeaList({ searchParams }: { searchParams: PageProps<"/teas">["searchParams"] }) {  const { q, page } = await searchParams;  const query = typeof q === "string" ? q : "";  const current = Number(page) || 1;  const found = findTeas(query);  const pages = Math.max(1, Math.ceil(found.length / perPage));  const shown = found.slice((current - 1) * perPage, current * perPage);  const link = (to: number) => ({ pathname: "/teas", query: query ? { q: query, page: to } : { page: to } });   return (    <>      <ul>        {shown.map((tea) => (          <li key={tea.slug}>{tea.name}</li>        ))}      </ul>      <p>        Page {current} of {pages}      </p>      {current > 1 && <Link href={link(current - 1)}>Previous</Link>}{" "}      {current < pages && <Link href={link(current + 1)}>Next</Link>}    </>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teasTea shop
Home Teas
Our teas
  • Green tea
  • Mint tea

Page 1 of 3

Next
Focus
nothing: the page itself
Scrolled
0 px down

Requests the page made during this step (3), without the files it loaded

  1. GET/teas200a whole page
  2. GET/200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /teas
  3. GET/200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /__PAGE__
    next-url
    /teas

Lines 6 to 8 call three hooks from next/navigation: useSearchParams for the query string, usePathname for the path, /teas, and useRouter for the router, which can move to another address from code. On every change, line 23 calls search with the box's text:

  • Line 11 copies the current query string into a new URLSearchParams, which, unlike the read-only one, can change.
  • Lines 12 and 13 set q to the text, or delete it when the box is empty. Line 14 deletes page: a new search starts on page 1.
  • Line 15 turns the pairs back into text, like q=green. Line 16 calls router.replace with the new address: /teas?q=green, or just /teas.

router.replace is a client-side navigation: Next.js's router changes the address and asks the server only for the new page's data, an RSC payload, instead of loading a whole HTML page (the first look at how Next.js works introduced both). The Steps tab shows it, recorded in real Chrome. Step 1 opens /teas, a page load. Step 2 clicks Next: the address becomes /teas?page=2, with one request, a fetch. Step 3 types green in the box: the address becomes /teas?q=green, page is gone, and the list shows Green tea and Jasmine green tea. Again one fetch, and no page load. The box kept its text and its focus, because the page was never loaded again.

A card, green, typed in the box, with an arrow to a card, slash teas question mark q equals green, the new address. An arrow goes on to a card, q green, the page's searchParams on the server. An arrow goes down to a window, the new list, holding Green tea and Jasmine green tea.greentyped in the box/teas?q=greenthe new address{ q: "green" }searchParams, on the serverthe new listGreen teaJasmine green tea
Typing green calls router.replace, which changes the address to /teas?q=green without a reload. The router asks the server for that address, the page reads searchParams, and the new list comes back.

The Next link in step 2 comes from app/teas/tea-list.tsx. Its href is an object: a pathname and a query. Link turns it into an address:

A Link whose href is an objectTSX
<Link href={{ pathname: "/teas", query: { q: "tea", page: 2 } }}>Next</Link>

This is a fragment of tea-list.tsx, with the values filled in. The recorded /teas?q=tea page has exactly this link: <a href="/teas?q=tea&page=2">. A link is an ordinary <a>, so it works before the JavaScript loads, and Next.js's router takes over the click once it has.

Why replace and not push? Both navigate the same way. Next.js's docs describe the difference: router.push adds an entry to the browser's history, so Back returns to the address before, while router.replace swaps the current entry. A search box changes the address on every letter; with replace, Back doesn't step through g, gr, gre. A Link pushes, which suits page links: Back goes to the page before. Linking and navigating covers the router in full.

Search and pages for the tea shop

Now the list itself. It reads two search params, q and page, shows two teas a page, and links to the pages before and after. Open the Browser tab, and click Next and Previous: each link switches to its recorded page.

Two teas a page, with the search kept in every linkNext.js
app/teas/
app/teas/tea-list.tsx
import Link from "next/link";import { findTeas } from "./data"; const perPage = 2; export default async function TeaList({ searchParams }: { searchParams: PageProps<"/teas">["searchParams"] }) {  const { q, page } = await searchParams;  const query = typeof q === "string" ? q : "";  const current = Number(page) || 1;  const found = findTeas(query);  const pages = Math.max(1, Math.ceil(found.length / perPage));  const shown = found.slice((current - 1) * perPage, current * perPage);  const link = (to: number) => ({ pathname: "/teas", query: query ? { q: query, page: to } : { page: to } });   return (    <>      <ul>        {shown.map((tea) => (          <li key={tea.slug}>{tea.name}</li>        ))}      </ul>      <p>        Page {current} of {pages}      </p>      {current > 1 && <Link href={link(current - 1)}>Previous</Link>}{" "}      {current < pages && <Link href={link(current + 1)}>Next</Link>}    </>  );}
Recorded from Next.js 16.4.3
localhost:3000/teas?q=teaTea shop
Home
Our teas
tea
  • Green tea
  • Mint tea

Page 1 of 3

The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.

  • Line 7 waits for searchParams and takes q and page. Line 8 keeps q only when it's one string.
  • Line 9 turns page into a number. Number("2") is 2. A missing page, or text like abc, gives NaN or 0, and || 1 turns those into page 1.
  • Line 11 counts the pages, and line 12 takes this page's two teas with slice.
  • Line 13 builds each link's href, keeping q so the search survives a change of page. Lines 25 and 26 show Previous and Next only when there's a page to go to.

The recording shows the result for every address the box and the links lead to: /teas?q=tea has five teas, so Page 1 of 3, and its Next link is /teas?q=tea&page=2. /teas?page=1 shows the same teas as /teas. Each of these addresses can be shared: whoever opens it gets the same page of the same search, made on the server.

URL state or component state?

Not everything belongs in the address. State in a component, with useState, lives in the browser, for as long as the component is on screen. State in the URL lives in the address. Each row asks one question of both:

Where state lives
QuestionIn the URLIn component state
Can a visitor share it or bookmark it?Yes. It's part of the address.No. It's gone when the page closes.
Does it survive a reload?Yes.No. It starts again from its first value.
Can the server read it?Yes: searchParams in a page.No. It only exists in the browser.
What does changing it cost?With Link or router.replace, as in this lesson, a navigation: the router asks the server for the new page's data.A re-render in the browser, with no request.

There is a third way, for URL state only the browser needs. Next.js's docs say you can also call the browser's own window.history.pushState and window.history.replaceState. They change the address without a reload, and useSearchParams and usePathname follow the change. The docs' example uses it for a sort order that a Client Component applies to a list it already has.

A good rule: if someone would want it in a link they share, or the server needs it to make the page, put it in the URL. If it's private, short-lived or changes many times a second, keep it in the component. Sort these:

URL or component state?
  • The text the list is filtered by
  • Which page of results is shown
  • Sort by price or by name
  • Whether a dropdown menu is open
  • Which tea card the mouse is over
  • A review the visitor is still typing
Try it yourself
0 of 6 correct

Sort each piece of the tea shop's state: should it live in the URL, or in component state?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "searchParams is a URLSearchParams." No. The prop is a promise of a plain object. Only useSearchParams returns a URLSearchParams, with .get().
  • "?page=2 gives the number 2." No. The recorded page got "2", a string. Use Number(page).
  • "Each name has one value." No. ?q=mint&q=green gave an array.
  • "useSearchParams works anywhere." It works in Client Components, inside a Suspense. Without one, the recorded build stopped.
  • "Changing the query string reloads the page." No. In real Chrome, router.replace and a Link changed it with a fetch, and the box kept its text.
  • "Search params make the whole page dynamic." Only the part that waits for them, inside Suspense. The heading and the nav were in the shell, made during the build.

Practice exercises

These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.

Exercise 1 · Warm-upA name given twice

The inspecting page from this lesson is asked for this address. What is sort in searchParams?

Starter codeText
/teas?sort=price&sort=name

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upThe type of page

    For this address, what type is page in searchParams?

    Starter codeText
    /teas?q=mint&page=2

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeWrite the address of a link

      Which address does this link point at?

      Starter codeTSX
      <Link href={{ pathname: "/teas", query: { q: "tea", page: 2 } }}>Next</Link>

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeSearch for coffee

        A visitor opens /teas?q=coffee. What does the list part of this page show?

        Starter codeTSX
        import { Suspense } from "react";
        import { findTeas } from "./data";
        
        export default function TeasPage({ searchParams }: PageProps<"/teas">) {
          return (
            <main>
              <h1>Our teas</h1>
              <Suspense fallback={<p>Loading teas…</p>}>
                <TeaList searchParams={searchParams} />
              </Suspense>
            </main>
          );
        }
        
        async function TeaList({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) {
          const { q } = await searchParams;
          const teas = findTeas(typeof q === "string" ? q : "");
        
          if (teas.length === 0) return <p>No teas match.</p>;
          return (
            <ul>
              {teas.map((tea) => (
                <li key={tea.slug}>{tea.name}</li>
              ))}
            </ul>
          );
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeFind why the build stops

          This is app/teas/page.tsx, and SearchBox is the box from this lesson. next build stops. What do you change?

          Starter codeTSX
          import SearchBox from "./search-box";
          
          export default function TeasPage() {
            return (
              <main>
                <h1>Our teas</h1>
                <SearchBox />
              </main>
            );
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeWhat the first HTML holds

            A visitor opens /teas?q=mint. Where the box will go, what does the HTML from the server hold?

            Starter codeTSX
            import { Suspense } from "react";
            import SearchBox from "./search-box";
            
            export default function TeasPage() {
              return (
                <main>
                  <h1>Our teas</h1>
                  <Suspense fallback={<p>Loading the search box…</p>}>
                    <SearchBox />
                  </Suspense>
                </main>
              );
            }
            

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask who has the query string at that moment: the build, the server on a request, or the browser.

              The Search params quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does searchParams hold for /teas?q=mint&page=2?

                Choose an answer to see the explanation.

              2. Question 2 of 7A page awaits searchParams at the top, with no Suspense. What does next build do with Cache Components on?

                Choose an answer to see the explanation.

              3. Question 3 of 7Why does a Client Component that calls useSearchParams need a Suspense around it?

                Choose an answer to see the explanation.

              4. Question 4 of 7The page reads nothing else from the request, and the search box is in Suspense. What is in the HTML for /teas?q=mint?

                Read the code, then predictTSX
                import { Suspense } from "react";
                import SearchBox from "./search-box";
                
                export default function TeasPage() {
                  return (
                    <main>
                      <h1>Our teas</h1>
                      <Suspense fallback={<p>Loading the search box…</p>}>
                        <SearchBox />
                      </Suspense>
                    </main>
                  );
                }
                

                Choose an answer to see the explanation.

              5. Question 5 of 7Which line makes the search box change the address?

                Read the code, then predictTSX
                "use client";
                
                import { usePathname, useRouter, useSearchParams } from "next/navigation";
                
                export default function SearchBox() {
                  const searchParams = useSearchParams();
                  const pathname = usePathname();
                  const router = useRouter();
                
                  function search(text: string) {
                    const params = new URLSearchParams(searchParams);
                    if (text) params.set("q", text);
                    else params.delete("q");
                    params.delete("page");
                    const query = params.toString();
                    router.replace(query ? `${pathname}?${query}` : pathname);
                  }
                
                  return (
                    <input
                      placeholder="Search teas"
                      defaultValue={searchParams.get("q") ?? ""}
                      onChange={(event) => search(event.target.value)}
                    />
                  );
                }
                

                Choose an answer to see the explanation.

              6. Question 6 of 7What does <Link href={{ pathname: "/teas", query: { page: 2 } }}> render?

                Choose an answer to see the explanation.

              7. Question 7 of 7Where should the open or closed state of a dropdown menu live?

                Choose an answer to see the explanation.

              Key takeaways

              • The query string, after the ?, is state kept in the address. Pages get it in the searchParams prop.
              • searchParams is a promise of a plain object. Values are strings, or arrays when a name repeats, and may be missing.
              • A query string isn't known during the build, and the server receives it with each request, so the part that reads it waits inside Suspense; the rest stays in the shell.
              • Client Components read it with useSearchParams, inside Suspense, and change it with router.replace or a Link with a query, without a reload.
              • Put state in the URL when it should be shared, survive a reload, or be read by the server. Keep the rest in components.

              Remember the one-liner.
              If someone should be able to share it, put it in the URL.

              Coming next: Route groups and private folders. Folders in brackets group routes without changing the address, and folders that start with _ stay out of routing.

              CompleteFrontend Clear concepts. Working examples.