URL state with searchParams and route params
How a Next.js Server Component reads filter, sort, and pagination state from the URL and validates it with Zod, so list views survive refresh and travel in a shared link.
Picture the invoice list on your dashboard. The user filters it to paid, sorts by date, and pages forward once. Then three ordinary things happen.
They copy the URL into a Slack message to a coworker. The coworker opens it and sees the same view: paid invoices, sorted by date, page two. They refresh the tab, and the filter is still there. They hit the back button, and the previous filter returns.
None of that is automatic. Someone decided where the filter, sort, and page state lives, and that decision is the whole lesson. The question to answer is this: where does that view state live, and how do you read it on the server?
There is a tempting wrong answer, and you’d reach for it on muscle memory. You’d put the filter in useState, add a useEffect that refetches when the filter changes, and render the result. It works on your screen, but the filter lives in the component’s memory, so it dies on refresh, it can’t be shared, and the back button does nothing. You’ve also wired up an effect to fetch data, and back in the chapter on effects the very first rule was that fetching in an effect is almost never what you want. That instinct was right, and this is one of the places it pays off.
The senior answer fits in one sentence: this state belongs in the URL, and a Server Component reads it directly. No client state, no effect, no waterfall. The URL is the filter. By the end of this lesson you’ll be able to read and validate the URL on the server and render a filtered list with nothing held in component memory.
One thing up front so you don’t go looking for it: this lesson is the read side. Writing the URL when the user clicks a filter chip, through the useRouter and useSearchParams hooks, is the next lesson. Here, the URL arrives and we read it.
In the first lesson of this chapter you learned that a route’s inputs are the URL, the headers, and the cookies, and nothing else. That lesson covered two of those: headers and cookies. This one covers the third and last: the URL.
What belongs in the URL
Section titled “What belongs in the URL”Settle the decision before any syntax, because everything after it is plumbing in service of it.
The rule is this: state that should survive a refresh, be shareable, or show up in browser history belongs in the URL, and transient state belongs in component state. An open dropdown, a hover, focus, the half-typed text in a search box before you submit it: those are component state. They die when the page reloads, and nobody wants to bookmark a half-open menu.
When you’re not sure which bucket something falls into, ask one question:
Would the user expect this state to come back if they refreshed the page?
If yes, it goes in the URL. If no, it’s useState. That single question resolves almost every case you’ll meet.
You already know the useState side of this line, since that’s where local UI state has always lived. What’s new is the other side: shareable, refresh-stable state does not belong in useState, it belongs in the URL. This section draws the boundary between those two homes, and the rest of the lesson trains you to place that boundary on instinct.
For any list-style view in a SaaS app, four kinds of state almost always belong in the URL, and you’ll reach for them on every table you build: filter, sort, pagination, and the active tab or view. Those are exactly the things a user expects to survive a refresh and to travel in a shared link.
The rule earns its keep on the cases that aren’t obvious, so it’s worth working through the edges.
Sort each piece of state into where it belongs. Watch the close calls — a submitted search and an unsubmitted one don't live in the same place, and neither do a tab and a dropdown. Drag each item into the bucket it belongs to, then press Check.
paid, overdue)The two pairs that trip people up are worth spelling out. A search query the user has submitted belongs in the URL, because they’d expect ?q=acme to come back on refresh and to work in a shared link. The text in the box before they hit enter does not, because that’s an in-progress edit and pure component state. The same word lives in two homes, split by whether the user has committed to it. Likewise, a selected tab is URL state, since it’s a view they’d share, while an open dropdown is not, since nobody shares an open menu. Once those two distinctions feel natural, the rest of the cases follow the same logic.
Two vehicles: params for identity, searchParams for view state
Section titled “Two vehicles: params for identity, searchParams for view state”The URL carries two kinds of information, and they ride in two different parts of it. Getting this split clear now saves you from a category of confusion later.
Route params carry identity: which org, which invoice, the nouns in the path. searchParams carry view state: filter, sort, page, the adjectives on the query. A single URL has both, and you read them through two different props. The path answers who, and the query answers how you’re looking at them.
Taking a real URL apart makes the split something you can see.
the [org] dynamic segment → params.org === 'acme'
reads as { status: 'paid', sort: '-date', cursor: 'eyJpZCI6NDJ9' }
key=value pair The [org] piece is a dynamic segment : a folder named [org] in your app/ directory, which you met when you first learned App Router routing. Whatever sits in that slot of the URL becomes params.org.
Now look at the page component that receives both. In the App Router, a page.tsx is handed its params and searchParams as props, and the file’s location on disk determines which params exist:
export default async function InvoicesPage(props: { params: Promise<{ org: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>;}) { // ...render the list}You only need to recognize this shape for now; the Promise wrapper gets its full explanation in a moment.
The shape of params here, { org: string }, comes directly from the [org] folder. Rename the folder, and the param renames with it. You don’t wire this up: the file system does. The searchParams type is looser on purpose: every value is string | string[] | undefined, and we’ll see why that matters later. The Promise wrapper around both is the heart of the read-on-server pattern, which the next section unpacks.
Reading them on the server: both are Promises
Section titled “Reading them on the server: both are Promises”In Next.js 16, params and searchParams don’t arrive as plain objects. They arrive as Promises, and you await them:
const { org } = await props.params;const { status, sort } = await props.searchParams;That await does real work rather than satisfying a formality. You met these request APIs in the previous chapter, where the point was that the route is dynamic by default, so resolving them is the request-time work: the value genuinely isn’t known until the request arrives. Under the Cache Components model, the await is also the explicit signal that this part of the render is dynamic. We come back to what that means for caching near the end of the lesson.
There’s a client-side counterpart you should be able to recognize, though you won’t write it here. A Client Component that’s handed a searchParams Promise unwraps it with React.use() instead of await, the same unwrapping shape you saw when crossing the server/client boundary. Reading the URL on the client is rare, though, and the next lesson covers it. The senior default, the same one the cookies-and-headers lesson taught, is to read high on the server and pass resolved values down as props. You read once, at the top, where the request is, and everything below receives plain values.
Validate at the boundary with Zod
Section titled “Validate at the boundary with Zod”Treat this step as mandatory: it’s a security decision rather than a style preference.
searchParams are user-controlled input. The address bar is a text field anyone can type into. A user, a crawler, or someone poking at your app can request ?status=lol, or ?sort=💀, or omit every parameter, or repeat one fifty times. Whatever they send arrives in your searchParams. If you pass that straight into a database query, the best case is a crash, and the worse cases are security holes.
So the rule is to parse every searchParams read through a Zod schema, at the top of the page, once. Valid values pass through, and invalid or missing ones fall back to sensible defaults. The schema does double duty: it’s the runtime gate and the written contract for what the URL is allowed to contain. Anyone reading the page can see exactly which filters and sorts are legal.
Here’s the canonical helper, one per route, called once at the top of the page:
import { z } from 'zod';
const InvoiceQuerySchema = z.object({ status: z.enum(['draft', 'paid', 'overdue']).optional(), sort: z.enum(['-date', 'date', '-total', 'total']).default('-date'), cursor: z.string().optional(),});
type InvoiceQuery = z.infer<typeof InvoiceQuerySchema>;
export function parseSearchParams(raw: unknown): InvoiceQuery { const result = InvoiceQuerySchema.safeParse(raw); return result.success ? result.data : InvoiceQuerySchema.parse({});}A few things here are deliberate. safeParse returns a result object, either { success: true, data } or { success: false, error }, and crucially it never throws. That matters, because a malformed link should render your default view, not a 500 error page. A user who clicks a URL someone mangled in a chat app should land on a sane invoice list, not a stack trace. Zod has far more depth than this, including refinements, transforms, and error formatting, but that’s a later chapter. Here you only need z.enum, .optional(), .default(), and safeParse.
Notice the sort default. Because sort has .default('-date'), the URL doesn’t need a sort param at all: a visit to /orgs/acme/invoices with no query string parses cleanly into { sort: '-date' }. Defaults are what let the URL stay short and omittable while the page still knows what to render.
Your turn to write the schema. The starter is too loose: status accepts any string, and sort has no default. Tighten it until every scenario lights up green, and watch the inferred type narrow as you do.
Tighten this schema. Constrain `status` to the three real statuses and make it optional; give `sort` an enum and a `.default('-date')`. Watch two things move as you go: the fixtures turn green, and the `^?` type shifts — `status` becomes optional (`status?`), while `sort` stays required because its default always fills it in. The empty-object fixture is the one to notice — it passes because the default fills in `sort`, which is what makes the param omittable.
| Test scenario | Value | |
|---|---|---|
| status filter applied | {"status":"paid"} | |
| no params (default fills sort) | {} | |
| bogus status | {"status":"lol"} | |
| explicit sort + status | {"sort":"-date","status":"draft"} | |
| bogus sort | {"sort":"sideways"} | |
| status + opaque cursor | {"status":"overdue","cursor":"eyJpZCI6NDJ9"} | |
The Server Component pattern: read, validate, query, render
Section titled “The Server Component pattern: read, validate, query, render”Here is the shape that replaces the whole client state machine. The contrast carries the point, so the two approaches sit side by side below.
'use client';
export function InvoiceList({ org }: { org: string }) { const [status, setStatus] = useState('paid'); const [invoices, setInvoices] = useState<Invoice[]>([]);
useEffect(() => { fetch(`/api/orgs/${org}/invoices?status=${status}`) .then((res) => res.json()) .then(setInvoices); }, [org, status]);
return <InvoiceTable invoices={invoices} />;}The reflex version. It renders, but the filter lives in memory: not shareable, not refresh-stable, and with an effect fetching data, exactly what the effects chapter told you to avoid.
export default async function InvoicesPage(props: { params: Promise<{ org: string }>; searchParams: Promise<Record<string, string | string[] | undefined>>;}) { const { org } = await props.params; const { status, sort, cursor } = parseSearchParams(await props.searchParams);
const invoices = await listInvoices({ org, status, sort, cursor });
return <InvoiceTable invoices={invoices} />;}The senior shape. The URL is the state; the server re-reads and re-renders on every URL change. No client state, no effect, no waterfall.
The difference is worth sitting with. The client version holds the filter in useState, so it evaporates on reload and can’t travel in a link. It also runs an effect to fetch, which means a waterfall: render, then fetch, then render again, the exact anti-pattern from the effects chapter. The server version holds nothing. The URL is the filter. When the URL changes, the server runs again from the top, reads the new value, queries, and renders. There is no second source of truth to keep in sync, because there’s only one source: the address bar.
It’s worth walking the server version line by line, because this is the page you’ll write again and again.
import { listInvoices } from '@/db/queries/invoices';
import { InvoiceTable } from './_components/invoice-table';import { parseSearchParams } from './_lib/search-params';
export default async function InvoicesPage(props: { params: Promise<{ org: string }>; searchParams: Promise<Record<string, string | string[] | undefined>>;}) { const { org } = await props.params; const { status, sort, cursor } = parseSearchParams(await props.searchParams);
const invoices = await listInvoices({ org, status, sort, cursor });
return <InvoiceTable invoices={invoices} />;}An async page component, handed both channels as Promises, which is why it has to be async. This signature is the only place the request enters, and everything below is plain values.
import { listInvoices } from '@/db/queries/invoices';
import { InvoiceTable } from './_components/invoice-table';import { parseSearchParams } from './_lib/search-params';
export default async function InvoicesPage(props: { params: Promise<{ org: string }>; searchParams: Promise<Record<string, string | string[] | undefined>>;}) { const { org } = await props.params; const { status, sort, cursor } = parseSearchParams(await props.searchParams);
const invoices = await listInvoices({ org, status, sort, cursor });
return <InvoiceTable invoices={invoices} />;}The identity read. await props.params resolves to { org }, the org we’re scoped to. This is the who from the path.
import { listInvoices } from '@/db/queries/invoices';
import { InvoiceTable } from './_components/invoice-table';import { parseSearchParams } from './_lib/search-params';
export default async function InvoicesPage(props: { params: Promise<{ org: string }>; searchParams: Promise<Record<string, string | string[] | undefined>>;}) { const { org } = await props.params; const { status, sort, cursor } = parseSearchParams(await props.searchParams);
const invoices = await listInvoices({ org, status, sort, cursor });
return <InvoiceTable invoices={invoices} />;}The validate-at-the-boundary line, the one never to skip. We await the raw, user-controlled query, hand it straight to the Zod helper, and get back typed, trusted { status, sort, cursor }. Garbage in the URL becomes defaults out, not a crash.
import { listInvoices } from '@/db/queries/invoices';
import { InvoiceTable } from './_components/invoice-table';import { parseSearchParams } from './_lib/search-params';
export default async function InvoicesPage(props: { params: Promise<{ org: string }>; searchParams: Promise<Record<string, string | string[] | undefined>>;}) { const { org } = await props.params; const { status, sort, cursor } = parseSearchParams(await props.searchParams);
const invoices = await listInvoices({ org, status, sort, cursor });
return <InvoiceTable invoices={invoices} />;}The data read. The parsed, trusted values flow in as arguments. listInvoices is a black box here, since how it builds the query against the database is a later chapter’s job. What matters is that only validated values reach it.
import { listInvoices } from '@/db/queries/invoices';
import { InvoiceTable } from './_components/invoice-table';import { parseSearchParams } from './_lib/search-params';
export default async function InvoicesPage(props: { params: Promise<{ org: string }>; searchParams: Promise<Record<string, string | string[] | undefined>>;}) { const { org } = await props.params; const { status, sort, cursor } = parseSearchParams(await props.searchParams);
const invoices = await listInvoices({ org, status, sort, cursor });
return <InvoiceTable invoices={invoices} />;}Render the result. Notice what isn’t here: no useState, no useEffect, no second copy of the filter to keep in sync. URL in, typed filters, query, table out, all on the server, on every render.
Those five steps are the whole pattern: a URL comes in, typed filters come out, the query runs, and the table renders. It all runs top to bottom on the server, fresh, on every render.
That raises the question of when, exactly, “every render” happens. The loop below is what makes the page feel alive, and notice that one step in it belongs to the next lesson.
%%{init: {'themeCSS': '.messageText, .messageText tspan { font-size: 20px !important; } .actor, .actor tspan { font-size: 18px !important; } .noteText, .noteText tspan { font-size: 16px !important; }'} }%%
sequenceDiagram
participant U as User
participant B as Browser (URL)
participant S as Server (page.tsx)
participant D as DB
U->>B: clicks the "Paid" filter chip
rect rgba(168, 85, 247, 0.14)
Note over B: URL updates to ?status=paid<br/>Client Component — lesson 5
end
rect rgba(56, 189, 248, 0.12)
Note over B,D: read → validate → query → render
B->>S: GET the new URL
S->>S: await + validate searchParams
S->>D: listInvoices({ status: 'paid', … })
D-->>S: matching rows
S-->>B: rendered table
end
B->>U: shows paid invoices
Note over U,D: Later — the same loop, a different trigger
U->>B: clicks Back
rect rgba(168, 85, 247, 0.14)
Note over B: URL returns to the previous query<br/>Client Component — lesson 5
end
rect rgba(56, 189, 248, 0.12)
B->>S: GET the previous URL
Note over B,D: read → validate → query → render, again
end That’s the mental model to carry out of here: the server is a pure function of the URL. Give it the same URL, and it renders the same page. The only moving part the client owns is changing the URL, which is what the next lesson is about.
Two shapes that will surprise you
Section titled “Two shapes that will surprise you”Two facts about real URLs break naive code. You only need to know they exist and how to absorb them at the parser; the deep mechanics come later.
Repeated keys become arrays
Section titled “Repeated keys become arrays”Add a multi-select filter, say tags, and a user picks two. The URL becomes ?tag=billing&tag=urgent. Now searchParams.tag is not a string. It’s ['billing', 'urgent']. Repeat a key, and its value is an array.
This is why the type of any searchParams value isn’t string. It’s this:
type SearchParamValue = string | string[] | undefined;Code that assumes string breaks the moment a key repeats, and it will pass every test you write until the day someone selects two tags. The fix is to handle the array case once, at the parser, so the rest of your page only ever sees one shape:
const TagsSchema = z .union([z.string(), z.array(z.string())]) .transform((value) => (Array.isArray(value) ? value : [value])) .default([]);Now whether the user picks one tag or five, everything downstream of the parser receives a string[]. You normalized the surprise away at the boundary, which is exactly where surprises should be handled.
Cursors are opaque on purpose
Section titled “Cursors are opaque on purpose”Look back at our example URL: cursor=eyJpZCI6NDJ9. That gibberish is a pagination cursor, and it’s gibberish on purpose.
A cursor encodes where the last page ended: the sort key of the final row, plus a tiebreaker so the ordering is unambiguous. That information is then base64-encoded into something opaque : meaningless to the user, perfectly deterministic for the server. The server hands it out, the browser carries it in the URL, and the server decodes it on the next request to know where to resume.
There are two reasons to make it opaque rather than a readable ?page=2. First, it isn’t state the user should edit, and encoding it discourages anyone from fiddling with a value that has to be internally consistent. Second, the encoded shape can evolve: you can add a tiebreaker field next quarter without breaking old links, because nothing outside the server ever parsed it. Decoding lives in your parse helper, right alongside the Zod schema, and the query consumes the decoded shape. The cursor mechanics, meaning how you build it, the tiebreaker rules, and how it drives pagination in a real list, are a later chapter’s job. For now, treat it as a string in the URL, opaque by design, decoded at the boundary.
Does reading searchParams change caching?
Section titled “Does reading searchParams change caching?”This is the question left open back at the await. The short answer first: reading searchParams costs you nothing you weren’t already paying.
Under the Cache Components model from the previous chapter, every route is dynamic by default. Reading searchParams is one of the explicit dynamic signals, but the route was already dynamic, so the read doesn’t make anything dynamic that wasn’t already. There’s no penalty for reading the URL versus not reading it.
There’s one interaction that does cause trouble, and it’s a build error rather than a runtime surprise:
The fix is the same move you saw in the previous chapter, and it’s the reason PPR exists: keep the cached chrome outside the dynamic part of the tree. The sidebar, the header, and the org nav are chrome, the same for every filter, so they stream instantly from the static cache. The invoice table is the dynamic hole: it reads searchParams and runs at request time. So rather than work against the build error, you lay the page out so the URL read happens only where the data is genuinely dynamic.
sidebar
searchParams
flows into the dynamic hole only
The picture gives you the intuition: searchParams flows into the inner box and nowhere else. You cache the shell and read the URL only where the data lives.
What the URL is not
Section titled “What the URL is not”You know what belongs in the URL. Knowing what to keep out of it matters just as much. Here are three boundaries.
Not a place for secrets. Everything in the URL leaks. It lands in server access logs, it rides along in the Referer header when the user clicks an outbound link, it sits in browser history, and it flows into analytics. Tokens, session identifiers, and internal IDs you don’t want strangers enumerating: none of it goes in the URL, ever. This is the same line the cookies lesson drew: the URL and headers are telemetry, and identity belongs in the session cookie. A value you’d be unhappy to find in a log file does not go in the address bar.
Not a place for large blobs. Keep your total URL state well under a kilobyte, because browsers and CDNs cap URL length and you’ll hit the limit. JSON-stringifying a fat object into a single param is brittle, and it bloats every request line and every log entry the URL touches. Use flat, named params. If you outgrow hand-rolling them, that’s the signal to reach for a real tool, which is the last section.
Not a place for transient UI state. This closes the loop back to the rule we opened with. An open dropdown, a hover, a half-typed input: the user would never bookmark those, never share them, never expect them back on refresh. They fail the one question, so they belong in useState.
nuqs: the type-safe URL-state layer
Section titled “nuqs: the type-safe URL-state layer”Everything so far, meaning searchParams, a Zod schema, and defaults, is the right amount of tool for a page with one filter or two, so there’s no need to reach past it early. There is a threshold worth knowing, though.
When a project grows past two or three URL-state surfaces, say a filter, a sort, a search, a cursor, and a tab all on the same view, with the same pattern repeated across half a dozen views, the hand-written parse-and-default code becomes a tax. Every new param is another enum, another default, another line in the helper, kept in sync by hand. That’s the moment nuqs pays for itself: a typed URL-state library that collapses parse, default, and type into one declaration.
nuqs gives you typed parsers, default values, and, on the client, a useQueryState hook that both reads and writes the URL. The writing half is the next lesson and a later chapter’s project; here we stay on the server read.
const InvoiceQuerySchema = z.object({ status: z.enum(['draft', 'paid', 'overdue']).optional(), sort: z.enum(['-date', 'date', '-total', 'total']).default('-date'),});
type InvoiceQuery = z.infer<typeof InvoiceQuerySchema>;
export function parseSearchParams(raw: unknown): InvoiceQuery { const result = InvoiceQuerySchema.safeParse(raw); return result.success ? result.data : InvoiceQuerySchema.parse({});}Fine for one surface. But every new param is another enum, another default, another branch in the helper, and the type is a separate z.infer you keep in sync by hand.
import { createSearchParamsCache, parseAsStringEnum,} from 'nuqs/server';
export const searchParamsCache = createSearchParamsCache({ status: parseAsStringEnum(['draft', 'paid', 'overdue']), sort: parseAsStringEnum(['-date', 'date', '-total', 'total']).withDefault( '-date', ),});One declaration is the parser, the default, and the type at once. And the same cache reads anywhere in the render tree: a deeply nested component calls searchParamsCache.get('status') without re-parsing the prop or threading it down. In the page, parse once:
const { status, sort } = await searchParamsCache.parse(props.searchParams);Note one detail that catches people out: the server parsers import from nuqs/server, not the bare nuqs. The top-level nuqs entry carries a 'use client' directive and would drag client code into your Server Component. The split is intentional, and server parsing lives behind /server.
Be clear about what the cache actually buys you. The page itself can already read its own searchParams prop directly, so you don’t need nuqs for that. What the cache gives you is one typed declaration shared across the whole render tree. You parse once at the page, then any nested Server Component reads the same parsed values with searchParamsCache.get(...), instead of re-parsing the prop or threading it down by hand. The API surface, namely createSearchParamsCache, parseAsStringEnum, .withDefault, .parse, and .get, is worth recognizing now; you’ll set it up properly when you build the full list view.
External resources
Section titled “External resources”The official shape of the params and searchParams props, and why they're Promises.
parse vs safeParse and the result object — the runtime gate your parse helper is built from.
createSearchParamsCache: the production URL-state layer and its server read pattern.
Ahmad Alfy on the URL as a state container — what belongs there and the one question that decides.