State that belongs in the URL
Dynamic segments identify which thing a page shows, as in /blog/hello-world. Search results, filters, and page numbers are a different kind of state: they describe how to show a listing. The web's convention for that is the query string, the part of a URL after the question-mark character, made of key=value pairs:
/search?q=shoes&page=2Putting this state in the URL instead of useState is what makes results shareable, so pasting the link shows a teammate the same filtered view. It also makes them bookmarkable and back-button friendly. Every serious store, search engine, and admin table works this way, and you will build one within weeks of writing Next.js at work.
A page component receives the parsed query string as the searchParams prop, and like params earlier in this unit, current Next.js makes it a Promise you must await:
// app/search/page.js export default async function SearchPage({ searchParams }) { const { q, page } = await searchParams; const results = await findProducts(q, Number(page ?? 1)); return <h1>{results.length} results for "{q}"</h1>; }
Two consequences to decode
Every value is a string, or missing. The URL is text, so page arrives as "2" rather than 2, and it is undefined when the visitor omits it entirely. Defensive parsing with a default is not optional here, because people hand-edit URLs and paste truncated links.
Reading searchParams makes the page dynamic. The server cannot know ?q=shoes at build time, so the page must render per request. Unit 7 turns this into a general rule. Dynamic segments behave differently: generateStaticParams can list slugs ahead of time, but nobody can enumerate every possible query a visitor might type.
Linking into a filtered view needs nothing special, just a query string in href:
<Link href="/search?q=shoes&page=2">Next page</Link>pageNumber: hardened pagination parsing
Query values arrive as untrusted text, so parsing them deserves real care. pageNumber(searchParams) takes an object like { page: "3" } and returns the page as an integer, defaulting to 1 whenever page is missing, is not a whole number, or is less than 1.
function pageNumber(searchParams) { const n = parseInt(searchParams.page, 10); return Number.isInteger(n) && n >= 1 ? n : 1; } console.log(pageNumber({ page: "3" })); console.log(pageNumber({})); console.log(pageNumber({ page: "abc" })); console.log(pageNumber({ page: "0" }));
Output
3 1 1 1
Why one check covers every bad case
parseInt(undefined, 10)andparseInt("abc", 10)both produceNaN, so a missing value and a garbage value collapse into the same failure.Number.isInteger(NaN)isfalse, which meansNumber.isInteger(n) && n >= 1rejectsNaN, decimals, zero, and negatives in a single expression.- Defaulting to 1 rather than throwing is the friendly choice. A visitor who mangles a URL sees page one instead of an error page.
Why filters belong in the query string
Compare two ways to store a product list's filters, ?category=lamps&sort=price in the URL versus a useState hook in a client component.
With the state in the URL, the filtered view is an address. It can be shared, bookmarked, and restored by the back button, which is the behaviour people expect from every store and search page they have ever used. With the state in useState, it lives only inside one browser tab and dies on reload. Sending someone the link sends them the unfiltered page.
Speed is not the difference between the two approaches. Where the state lives is.
| Concern | ?category=lamps in the URL | useState |
|---|---|---|
| Share the exact view | Yes, paste the link | No |
| Survives a reload | Yes | No |
| Back button restores it | Yes | Not without extra work |
| Readable by the server | Yes, via searchParams | No |
Why a search page cannot be prerendered
A page that reads searchParams to show ?q= results cannot be prerendered as static HTML by next build.
The query string only exists at request time, and there is no way to enumerate every possible ?q= value the way generateStaticParams enumerates a known list of slugs. Reading searchParams therefore marks the page as dynamic, rendered fresh per request.
Why it works out that way
- Compare it with a
[slug]route, wheregenerateStaticParamscan enumerate the values because the set of posts is finite and known at build time. Search queries are neither. - This is the bridge to Unit 7's rule: a page becomes dynamic the moment it does something only knowable at request time, whether that is reading a query string, a cookie, or a header.