The list-view anatomy
Build the mental model for a production list view whose filter, sort, search, and pagination all live as shareable URL state, read on the server and written on the client with nuqs.
Picture the invoices screen of any SaaS app you’ve used. Someone filters it to overdue, sorts by amount so the biggest debts float to the top, types a customer’s name into the search box, and clicks through to the second page of results. Then they copy the address bar, drop it in Slack, and write “can you chase these?” Their teammate clicks the link and lands on the exact same view: same filter, same sort, same search, same page. Nobody has to rebuild anything.
That last part is the whole job. A real list screen isn’t one feature; it’s four working together (filter, sort, search, and paginate), and the bar it has to clear is that the screen’s entire state lives in a link you can share. You already have the raw materials. In URL state with searchParams and route params you learned to read one URL parameter in a Server Component and validate it, and in Client-side navigation hooks you learned to write one back from the client. Reading one and writing one is the primitive. This lesson composes them into a whole screen.
By the end you’ll hold a single mental model for the pattern: what belongs in the URL and what doesn’t, which side of the server/client line reads it and which side writes it, and what the shared link actually guarantees. You’ll also have the production setup the rest of this chapter builds on, which is one shared parser module, one server-side cache, and typed setters on the client. There are no new mechanics here, just the architecture that turns four moving parts into one coherent screen. There’s no code yet; the model comes first.
The four pillars of a list view
Section titled “The four pillars of a list view”Every list screen worth building leans on the same four pillars. It’s tempting to treat them as four separate features you bolt on one at a time, but that framing is what trips people up. They aren’t four decisions. They’re one shape with four parts: a single description of what the user is currently looking at.
- Filter: which subset of rows is shown. “Only overdue invoices.”
- Sort: what order they’re in. “Largest amount first.”
- Search: free-text matching within that subset. “Rows mentioning Acme.”
- Paginate: which slice of the result the user is on. “The second page.”
Read those four together and you get a complete sentence: the overdue invoices mentioning Acme, largest first, page two. That sentence is the state of the screen. Because it’s one sentence, it wants to live in one place, the URL, where each pillar owns a small, readable fragment of the address.
The following diagram maps a list-view toolbar to the URL it produces. Each control is labeled with the pillar it represents and the slice of the address it writes.
&cursor=… Four controls, four fragments, one URL: the whole screen above is /invoices?status=overdue&sort=-total&q=Acme&cursor=…. The table isn’t a control; it’s the rows those four parameters produce.
Notice what that diagram is really claiming: every one of the four controls defaults to URL state. That’s the throughline of the chapter. But “defaults to” is doing a lot of work. Not everything on a list screen belongs in the URL, and knowing the difference is the first real skill here. The next section gives you the rule.
What belongs in the URL, and what doesn’t
Section titled “What belongs in the URL, and what doesn’t”Here’s the rule you want to build a reflex around, because beginners get this wrong in both directions. Some reach for useState for everything and lose the shareable link; others stuff every twitch of the UI into the address and produce URLs nobody can read.
The rule is one question. For any piece of state on the screen, ask:
Would the user expect this back if they refreshed the page?
If yes, it belongs in the URL. If no, it’s transient interaction state and belongs in component state. That single question resolves almost every case, because “expect it back after a refresh” is really three overlapping things at once: the state should survive a reload, it should travel in a shared link, and it should land in the browser’s back/forward history. URL state is exactly the state for which all three are true.
Sort the screen’s state through that question and it falls cleanly into two piles.
In the URL, the things the user would absolutely expect to survive:
- the active filters (
status=overdue) - the current sort (
sort=-total) - the committed search term (the query they actually ran)
- the current page or cursor
In component state, the things they’d be surprised to see persisted:
- whether the filter dropdown is currently open
- hover and focus
- the text someone is typing into the search box but hasn’t submitted yet
- the row currently being edited inline (that’s form state, a different animal)
The third item in that list is subtler than it looks, and it’s worth flagging now so it doesn’t surprise you later. There’s a split between the text a user is typing and the term they’ve committed. The committed term is URL state, because it’s what the query ran with. The in-progress keystrokes are not, because you don’t want a new history entry for every letter. Holding both correctly is its own small craft, and a later lesson in this chapter is dedicated to it. For now, just file it away: typed is local, committed is in the URL.
A few things never belong in the URL no matter what the refresh question says, because the URL is public, durable, and human-readable by design. Don’t put secrets in it, since a shared link leaks them. Don’t put large blobs in it, since there’s a length budget and you’ll blow it. And don’t put anything in it the user couldn’t reasonably understand by reading it. An opaque encoded blob in the address is a warning sign, with exactly one sanctioned exception you’ll meet later: a pagination cursor, which has to be opaque because it encodes a database position.
That’s the rule stated. Stating a rule and applying it are different skills, though, and this one rewards practice. Walk the following decision tree a few times with different pieces of list-view state in mind. It forces you through the same questions an experienced engineer asks without thinking.
It survives a refresh, travels in a shared link, and lands in the browser’s back/forward history. All three are true, so the URL is its home.
Canonical examples: the active filter (status=overdue), the current sort (sort=-total), the committed search term, the current page or cursor.
Transient, per-session, or mid-interaction: the user wouldn’t expect it back after a reload, and a shared link shouldn’t carry it. Keep it in useState, never in the address.
Canonical examples: whether a dropdown is open or closed, hover and focus, the half-typed search string before it’s submitted, the row being edited inline.
The share-and-refresh contract
Section titled “The share-and-refresh contract”Once you’ve decided the four pillars live in the URL, you’ve made a promise to the user, whether you meant to or not. It’s worth naming that promise explicitly, because it’s the thing you test against. Call it the share-and-refresh contract, and state it as four guarantees:
- New tab: open the URL fresh and you get the same filtered, sorted, paginated view.
- Refresh: reload and nothing changes.
- Share: paste it into Slack and your coworker sees the same view (assuming they’re allowed to).
- Back button: going back returns to the previous filter, sort, and page combination, one step at a time.
This is the acceptance test for any URL-state list view. The question isn’t “does the filter work,” it’s “does the URL hold the truth.” And there’s a single litmus test that catches every violation of it:
That sentence is the most portable thing in this lesson, and you’ll reach for it every time you review a list screen.
One honest caveat, because the contract is precise about what it guarantees. The URL pins the view parameters, not a frozen photograph of the rows. If a teammate marks an invoice paid in the minute between you sharing the link and them opening it, they’ll see the current data under the same filter: overdue, sorted by amount, page two of whatever now matches. That’s almost always what you want, because a shared link is a saved question, not a saved answer. (Cursor pagination adds a wrinkle here, since a cursor points at a position rather than a snapshot, but that’s the pagination lesson’s concern, not this one’s.)
And one boundary to name and move past: a shared URL to something the recipient isn’t allowed to see is stopped at the route, not the URL. The link can say ?status=overdue all it likes; whether this user gets to see this organization’s invoices is an auth and tenancy check on the page itself, the exact machinery you just built across the auth and organizations units. The URL carries the view; the route enforces who’s allowed to render it.
Read on the server, write on the client
Section titled “Read on the server, write on the client”So the state lives in the URL. The next question is the architectural one: who reads it, who writes it, and what happens in between? The answer is a clean split with no middle layer, and that “no middle layer” part is what makes it feel different from the single-page-app habits you might be carrying.
- The server reads. The page is a Server Component. It reads
searchParams, validates and parses them, runs the database query with those parameters, and renders the table with real rows already in it. - The client writes. The controls (the filter dropdown, the sort header, the search box, the pagination buttons) are Client Components. Each one receives the current parsed value as a prop handed down from the server, and when the user changes it, writes the new value into the URL. That URL change makes the server re-render the page with the new parameters, and the fresh table streams back.
That’s the entire loop: change a control, write the URL, the server re-renders, a new table comes back. The data round-trips through the URL and the server, never through a client-side fetch.
Two habits from the single-page-app world will fight you here, so it helps to name them:
- No
useEffectsyncing state to the URL. You will not hold the filter inuseStateand then write an effect to push it into the address. The URL is the state. The control reads from it and writes to it directly, so there’s no second copy to keep in sync. - No client-side data fetch. The table is not fetched in the browser. The server already has the parsed parameters, so it queries and renders. There is no
useEffect(() => fetch(...)), and therefore no request waterfall where the page loads, then the data loads, then it pops in.
If your instinct on hearing “filtered list” was to reach for client state and a fetch, that instinct is from a different architecture. Here the server is the one with the database connection and the parsed URL, so the server does the work.
The clearest way to see why this matters is to watch a single request move through the system. In the trace below, scrub through the phases of loading /invoices?status=overdue. Pay attention to two moments: when each component runs, and when the client control becomes interactive.
Every node runs on the server first, including StatusFilter. "use client"
marks where hydration will later attach; it does not mean “skip the server”. The
table’s rows are read right here, from the validated searchParams, and rendered
straight to HTML.
Only now does StatusFilter wake up and become able to write the URL. InvoiceList
shipped zero client JavaScript, because its work was finished on the server.
That trace clears up two misconceptions. First, a "use client" component doesn’t skip the server: it renders on the server first, then hydrates. Second, the data isn’t fetched on the client: InvoiceList reads the parsed params and queries on the server, shipping no JS at all.
Now the write half. When a control changes the URL, it has a choice you met in Client-side navigation hooks: push or replace. For list state, the default is replace, with { scroll: false }, and the reasoning is pure user experience. Every filter tweak, sort flip, and page step is the same screen reconfigured, not a new destination. If each one pushed a history entry, a user who clicked five filters would have to hit back five times just to leave the page. replace swaps the current entry in place, so the back button still does the useful thing and leaves the list, instead of rewinding chips one at a time. The { scroll: false } keeps a long list from jumping to the top every time a control changes.
push isn’t wrong; it’s reserved. Clicking a row to open its detail page is genuine navigation, a new destination you’d want back to return from, so that gets push. The policy is simple: reconfiguring the current view replaces, and navigating to a new view pushes.
// Reconfiguring this view — stay put in history, don't scroll.router.replace('?status=overdue', { scroll: false });
// Navigating to a new view — back should return here.router.push('/invoices/inv_123');When hand-rolling stops scaling
Section titled “When hand-rolling stops scaling”You can already write all of this by hand. Reading a parameter on the server, validating it, and writing it back from the client with router.replace are the primitives from the request-surface chapter, and for one parameter they’re completely fine. The trouble is that a list view doesn’t have one parameter. It has four, and the hand-rolled approach degrades in a specific, visible way as they pile up.
Compare the two tabs below. The first is one filter: clean, readable, nothing you’d flinch at. The second is the same shape stretched to all four pillars.
'use client';
export const StatusFilter = ({ value }: { value: string | null }) => { const router = useRouter(); const searchParams = useSearchParams();
const onChange = (status: string) => { const next = new URLSearchParams(searchParams); next.set('status', status); router.replace(`?${next}`, { scroll: false }); };
return ( <select value={value ?? ''} onChange={(e) => onChange(e.target.value)}> {/* options */} </select> );};Fine, and you can already write every line. Read the current params, clone them, set one key, replace. Nothing here you’d flinch at.
const onChange = (key: string, value: string) => { const next = new URLSearchParams(searchParams); // set this one key without trampling the other three if (value) { next.set(key, value); } else { next.delete(key); } router.replace(`?${next}`, { scroll: false });};The same shape, now a tax. Every control routes through one generic handler that has to set its own key without trampling the siblings and strip empties by hand so the URL stays short, and that dance repeats on every change.
And the server has to grow to match. Where one parameter was a single Zod parse, four become an object schema, each field carrying its own default and fallback, hand-maintained in a second file that has to stay in lock-step with the client.
const Schema = z.object({ status: z.enum(['draft', 'paid', 'overdue']).nullable().catch(null), sort: z.enum(['createdAt', '-createdAt', 'total', '-total']).catch('-createdAt'), q: z.string().catch(''), cursor: z.string().optional(),});
const { status, sort, q, cursor } = Schema.parse(sp);Every parameter multiplies the boilerplate, and each one is a place to forget a default, forget validation, or accidentally trample a sibling key. The first tab is fine. The second is a tax, and notice it’s not a hard problem, just a repetitive one with several places to slip. That’s the signature of a problem a library should own. Here’s the threshold, stated as a rule you can apply without thinking:
A list view has filter + sort + search + pagination. That’s four parameters, past the point where hand-rolling pays off. Reach for a dedicated URL-state library.
The URLSearchParams API you’ve been cloning in those handlers is the right primitive for one or two parameters. Four is where it stops scaling. The tool for the threshold is nuqs.
nuqs: one parser module, both sides of the boundary
Section titled “nuqs: one parser module, both sides of the boundary”You met the name nuqs once, in passing. Now it earns its place. nuqs is the canonical URL-state tool for this stack, and it’s the chapter’s tool from here on. It exists to erase exactly the friction the four-parameter tab just showed, and it does it in three ways that map one-to-one onto those pain points.
Typed parsers. Instead of hand-writing a Zod schema and remembering to .catch() a default on every field, you describe each parameter with a parser builder: parseAsString, parseAsInteger, parseAsStringEnum, parseAsArrayOf, parseAsBoolean, parseAsIsoDate. Each one validates-or-defaults by design: feed it garbage from the URL and it falls back instead of letting the garbage flow into your query. That’s the same defense-at-the-boundary behavior you’d write with Zod, built into the parameter declaration.
Defaults that strip themselves. You attach a default with .withDefault(...), and here’s the payoff: when a parameter equals its default, nuqs removes it from the URL entirely. There’s no by-hand delete dance. The empty URL /invoices becomes the home view, and the address only ever shows what differs from the baseline. The reflex worth building: pick defaults that match the most common view, so the cleanest URL is also the most useful one.
One shared definition. This is the part that matters most for the rest of the chapter. The same parser objects feed the server and the client. On the server they go into a cache that parses searchParams; on the client they go into hooks that read and write them. The shape is declared once and both sides agree by construction, so two files can’t drift apart.
Setting it up takes three concrete moves.
Move 1: the adapter, once
Section titled “Move 1: the adapter, once”nuqs needs one piece of plumbing at the root of the app: an adapter that teaches it how this framework’s router works. You wrap the root layout’s children in it and never think about it again.
import { NuqsAdapter } from 'nuqs/adapters/next/app';
export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body> <NuqsAdapter>{children}</NuqsAdapter> </body> </html> );}That’s it for plumbing. One wrapper, set once.
Move 2: the shared parser module
Section titled “Move 2: the shared parser module”This is the reference shape for the whole chapter, so look at it closely. You define every parameter’s parser in one module that sits right next to the page, app/invoices/searchParams.ts, and from those same parsers you build the server-side cache. The module is the single source of truth: the client controls in later lessons will import these exact parsers, and the server will read through this exact cache.
import { createSearchParamsCache, parseAsString, parseAsStringEnum,} from 'nuqs/server';
export const STATUS_VALUES = ['draft', 'paid', 'overdue'] as const;export type Status = (typeof STATUS_VALUES)[number];
const SORT_VALUES = ['createdAt', '-createdAt', 'total', '-total'] as const;
export const statusParser = parseAsStringEnum(STATUS_VALUES).withDefault(null);export const sortParser = parseAsStringEnum(SORT_VALUES).withDefault('-createdAt');export const searchParser = parseAsString.withDefault('');export const cursorParser = parseAsString;
export const searchParamsCache = createSearchParamsCache({ status: statusParser, sort: sortParser, q: searchParser, cursor: cursorParser,});Everything comes from nuqs/server. createSearchParamsCache is the server-side reader; the parseAs* builders describe individual parameters.
import { createSearchParamsCache, parseAsString, parseAsStringEnum,} from 'nuqs/server';
export const STATUS_VALUES = ['draft', 'paid', 'overdue'] as const;export type Status = (typeof STATUS_VALUES)[number];
const SORT_VALUES = ['createdAt', '-createdAt', 'total', '-total'] as const;
export const statusParser = parseAsStringEnum(STATUS_VALUES).withDefault(null);export const sortParser = parseAsStringEnum(SORT_VALUES).withDefault('-createdAt');export const searchParser = parseAsString.withDefault('');export const cursorParser = parseAsString;
export const searchParamsCache = createSearchParamsCache({ status: statusParser, sort: sortParser, q: searchParser, cursor: cursorParser,});The allowed statuses are declared once as const, and the Status type is derived from that array. Define the values in one place and both the parser and the controls read the same source.
import { createSearchParamsCache, parseAsString, parseAsStringEnum,} from 'nuqs/server';
export const STATUS_VALUES = ['draft', 'paid', 'overdue'] as const;export type Status = (typeof STATUS_VALUES)[number];
const SORT_VALUES = ['createdAt', '-createdAt', 'total', '-total'] as const;
export const statusParser = parseAsStringEnum(STATUS_VALUES).withDefault(null);export const sortParser = parseAsStringEnum(SORT_VALUES).withDefault('-createdAt');export const searchParser = parseAsString.withDefault('');export const cursorParser = parseAsString;
export const searchParamsCache = createSearchParamsCache({ status: statusParser, sort: sortParser, q: searchParser, cursor: cursorParser,});parseAsStringEnum(STATUS_VALUES) constrains the parameter to exactly those literals. .withDefault(null) makes “no filter” the baseline, so a hand-typed ?status=DROP TABLE falls back to null instead of reaching the query.
import { createSearchParamsCache, parseAsString, parseAsStringEnum,} from 'nuqs/server';
export const STATUS_VALUES = ['draft', 'paid', 'overdue'] as const;export type Status = (typeof STATUS_VALUES)[number];
const SORT_VALUES = ['createdAt', '-createdAt', 'total', '-total'] as const;
export const statusParser = parseAsStringEnum(STATUS_VALUES).withDefault(null);export const sortParser = parseAsStringEnum(SORT_VALUES).withDefault('-createdAt');export const searchParser = parseAsString.withDefault('');export const cursorParser = parseAsString;
export const searchParamsCache = createSearchParamsCache({ status: statusParser, sort: sortParser, q: searchParser, cursor: cursorParser,});Sort is an enum too, the indexable columns, each with a - prefix for descending. The default -createdAt (newest first) is the most common view, so the clean URL is also the useful one.
import { createSearchParamsCache, parseAsString, parseAsStringEnum,} from 'nuqs/server';
export const STATUS_VALUES = ['draft', 'paid', 'overdue'] as const;export type Status = (typeof STATUS_VALUES)[number];
const SORT_VALUES = ['createdAt', '-createdAt', 'total', '-total'] as const;
export const statusParser = parseAsStringEnum(STATUS_VALUES).withDefault(null);export const sortParser = parseAsStringEnum(SORT_VALUES).withDefault('-createdAt');export const searchParser = parseAsString.withDefault('');export const cursorParser = parseAsString;
export const searchParamsCache = createSearchParamsCache({ status: statusParser, sort: sortParser, q: searchParser, cursor: cursorParser,});The search term is a plain string defaulting to empty; the cursor is a plain string with no default, where absent means “first page”. Both are simple because neither is constrained to a fixed set.
import { createSearchParamsCache, parseAsString, parseAsStringEnum,} from 'nuqs/server';
export const STATUS_VALUES = ['draft', 'paid', 'overdue'] as const;export type Status = (typeof STATUS_VALUES)[number];
const SORT_VALUES = ['createdAt', '-createdAt', 'total', '-total'] as const;
export const statusParser = parseAsStringEnum(STATUS_VALUES).withDefault(null);export const sortParser = parseAsStringEnum(SORT_VALUES).withDefault('-createdAt');export const searchParser = parseAsString.withDefault('');export const cursorParser = parseAsString;
export const searchParamsCache = createSearchParamsCache({ status: statusParser, sort: sortParser, q: searchParser, cursor: cursorParser,});The four parsers compose into one cache. This object is the single source of truth: the server parses through it, and the client imports these very parsers. Define the shape once, agree everywhere.
The quiet win here is type safety. Because statusParser is parseAsStringEnum([...]).withDefault(null), the value you get out isn’t a vague string | undefined; it’s the exact union the parser describes. Hover the parsed status and the type proves it:
const { status, sort, q, cursor } = await searchParamsCache.parse(props.searchParams);One point that holds even with nuqs in place: the parsers are your validation layer, and you still need one because searchParams is user-controlled. Anyone can type anything into the address bar. parseAsStringEnum rejecting ?status=DROP%20TABLE and falling back to the default is the boundary defense. It’s not a reason to skip validation; it’s how validation is expressed here for the common cases. The only time you’d reach back for hand-written Zod is a parameter structured beyond what the built-in parsers cover, a JSON-shaped filter, say. For enums, strings, integers, dates, and arrays, the parser is the validation.
Move 3: reading it on the server
Section titled “Move 3: reading it on the server”The page reads the whole thing in one line. You already know the shape from the request-surface chapter: searchParams arrives as a Promise, so you await it, except now you await the cache’s parse of it, which hands back a fully typed, validated object.
export default async function InvoicesPage(props: { searchParams: Promise<Record<string, string | string[] | undefined>>;}) { const { status, sort, q, cursor } = await searchParamsCache.parse(props.searchParams);
const { rows, nextCursor } = await listInvoices({ status, sort, q, cursor });
// controls receive their current value as props — built in later lessons return <InvoiceTable rows={rows} />;}One await searchParamsCache.parse(...), one typed object, and from there it’s ordinary code: pass the slices to the query function, and pass the current values down to the controls. The query itself is tenant-scoped, since it always filters to the current organization, but that’s the auth machinery from earlier, not something the URL touches.
The page-shape anatomy, end to end
Section titled “The page-shape anatomy, end to end”Time to put the pieces in one place and see the architecture whole. What follows is deliberately a skeleton: the page, the shared parser module, and one fully-built client control. The other three controls are stubs, because each one is its own lesson. The point here isn’t a finished screen; it’s the shape every later lesson plugs into.
Three files sit together, the parser module right beside the page that uses it:
Directoryapp/
Directoryinvoices/
- page.tsx Server Component: reads + queries + renders
- searchParams.ts shared parsers +
searchParamsCache Directory_components/
- status-filter.tsx one client control (the rest land in later lessons)
Now the three tabs. Read them as one unit: the parser module defines the shape, the page reads it and renders, and the one client control writes it back.
import { createSearchParamsCache, parseAsString, parseAsStringEnum,} from 'nuqs/server';
export const STATUS_VALUES = ['draft', 'paid', 'overdue'] as const;export type Status = (typeof STATUS_VALUES)[number];
const SORT_VALUES = ['createdAt', '-createdAt', 'total', '-total'] as const;
export const statusParser = parseAsStringEnum(STATUS_VALUES).withDefault(null);export const sortParser = parseAsStringEnum(SORT_VALUES).withDefault('-createdAt');export const searchParser = parseAsString.withDefault('');export const cursorParser = parseAsString;
export const searchParamsCache = createSearchParamsCache({ status: statusParser, sort: sortParser, q: searchParser, cursor: cursorParser,});The single source of truth. Both the page below and the control beside it read from these exact parsers, so the shape is declared once.
import { searchParamsCache } from './searchParams';import { StatusFilter } from './_components/status-filter';import { InvoiceTable } from './_components/invoice-table';
export default async function InvoicesPage(props: { searchParams: Promise<Record<string, string | string[] | undefined>>;}) { const { status, sort, q, cursor } = await searchParamsCache.parse(props.searchParams); const { rows, nextCursor } = await listInvoices({ status, sort, q, cursor });
return ( <div> <StatusFilter value={status} /> {/* <SortControl value={sort} /> — added in a later lesson */} {/* <SearchInput initialQuery={q} /> — added in a later lesson */} <InvoiceTable rows={rows} /> {/* <Pagination cursor={cursor} hasNext={nextCursor != null} /> — added in a later lesson */} </div> );}The page reads, validates, queries, and renders. Real controls take the server-derived current value as a prop; the table is server-rendered with data. The stubs name which lesson fills each one in.
'use client';
import { useQueryState } from 'nuqs';
import { type Status, statusParser } from '../searchParams';
export const StatusFilter = ({ value }: { value: Status | null }) => { const [, setStatus] = useQueryState('status', statusParser);
return ( <select value={value ?? ''} onChange={(event) => setStatus((event.target.value || null) as Status | null)} > <option value="">All statuses</option> <option value="draft">Draft</option> <option value="paid">Paid</option> <option value="overdue">Overdue</option> </select> );};The one control built in full. It renders from the server-parsed value prop and takes only the setter from useQueryState, wired to the same statusParser the cache uses, so client write and server read agree by construction. setStatus writes the URL (using replace by default), which re-renders the server and streams a fresh table. No useEffect, no fetch.
Step back and read the shape off those three files. Here is the sentence to memorize: the page reads, validates, queries, and renders; the client controls take their current value as a prop and write back via a setter; one parser module defines the shape for both sides. That’s the architecture. The lessons that follow don’t change it; they fill in the stubs.
Before moving on, here are two quick checks on the load-bearing decisions of this lesson.
A user clicks the status filter to open its dropdown, scans the options, then clicks away to close it again — without selecting anything. Where should that open/closed state live?
In the URL as ?dropdownOpen=true, so reopening the page restores it exactly where they left off.
In component state — it’s a momentary gesture, and a teammate opening the shared link would be baffled to find a dropdown already hanging open.
In the shared searchParams.ts parser module, right beside the status parser, since both concern the same control.
Persisted on the user’s profile in the database, so their preferences follow them across devices.
useState and never touches the URL. Living beside status in the parser module would still push it into the address; the database would make a momentary flicker outlive the session entirely.A user hand-edits the address bar to ?sort=passwordHash — a column the list isn’t allowed to sort on — and hits enter. What keeps that value from reaching the database query?
Nothing automatic — you’d guard against it with a runtime if inside the query function before it builds the orderBy.
parseAsStringEnum in searchParams.ts: the value isn’t one of its declared literals, so it falls back to the -createdAt default before the page ever queries.
The <select> in status-filter.tsx — it only ever renders the allowed options, so no invalid value can be chosen.
TypeScript, which narrows sort to the column union and rejects passwordHash when the project compiles.
parseAsStringEnum accepts only its declared literals and falls back to the default on anything else, so the rogue value never reaches the query. The <select> only constrains what the UI can produce — a hand-typed URL skips it entirely — and TypeScript checks types at build time, not the user input that arrives at runtime.What this lesson set up, and what comes next
Section titled “What this lesson set up, and what comes next”You now hold the model. The URL is the source of truth for any view state that should survive a refresh, a share, or a back button. The Server Component reads and validates it at the page boundary; Client Components write it through setters that replace rather than push. One parser module defines the shape for both sides. And the whole thing is graded by one test, share-and-refresh: if a click changes the result but not the URL, you haven’t built it yet.
What you have is a scaffold, and the rest of the chapter fills it in:
- The next lesson builds the
<SortControl />and the real filter shapes (single-select, multi-select, and ranges), plus the invariant that changing a filter or the sort resets pagination. - The lesson after that builds
<SearchInput />properly, with the typed-vs-committed split this lesson flagged and the React 19 input rhythm that keeps it responsive without firing a query per keystroke. - The last lesson builds
<Pagination />and makes the cursor-by-default call.
Each one imports the same searchParams.ts and slots into the same page shape. The architecture doesn’t move; you just keep filling the stubs.
External resources
Section titled “External resources”If you want to go deeper than this lesson, these four are worth a bookmark.
Parser builders, the server cache, and the client hooks, with every option this chapter uses.
The page that documents createSearchParamsCache — the exact reference shape this lesson builds.
The framework reference for the searchParams prop the page reads.
A long-form walkthrough from the library's author: custom parsers, the declarative search-params pattern, and rate limiting.