The list-view anatomy
The mental model for a list view whose filter, sort, search, and pagination 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, types a customer’s name into the search box, and clicks through to page two. They paste the address bar into Slack: “can you chase these?” Their teammate clicks the link and lands on the exact same view: same filter, sort, search, and page, nothing to rebuild.
A real list screen is four features working together (filter, sort, search, and paginate), and the bar it has to clear is that the screen’s entire state lives in a shareable link. You already have the raw materials. In URL state you read one URL parameter in a Server Component and validated it; in Navigation hooks you wrote one back from the client. This lesson composes that primitive into a whole screen and gives you the production setup the rest of the chapter builds on: one shared parser module, one server-side cache, and typed setters on the client.
The four pillars of a list view
Section titled “The four pillars of a list view”Every list screen leans on the same four pillars. They look like four separate features you bolt on one at a time, but 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 together, the four make a complete sentence: the overdue invoices mentioning Acme, largest first, page two. That sentence is the state of the screen, so it wants to live in one place, the URL, where each pillar owns a small, readable fragment of the address.
&cursor=… Four controls, four fragments, one URL: /invoices?status=overdue&sort=-total&q=Acme&cursor=…. The table isn’t a control; it’s the rows those four parameters produce.
Each of the four controls defaults to URL state, the default this chapter builds on. But not everything on a list screen belongs in the URL. 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”Reach for useState everywhere and you lose the shareable link; push every UI twitch into the address and you get URLs nobody can read. One question sorts it out. 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.
Run the screen’s state through that question and it falls into two piles.
In the URL, the things a user expects 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 open
- hover and focus
- the text being typed into the search box but not yet submitted
- the row being edited inline (that’s form state, a different animal)
The third item hides a distinction worth pinning down: the text a user is typing versus 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. Typed is local, committed is in the URL.
A few things never belong in the URL, whatever the refresh question says, because the URL is public, durable, and human-readable. No secrets: a shared link leaks them. No large blobs: the URL has a length budget you’ll blow. And nothing the user couldn’t understand by reading it; an opaque encoded blob is a warning sign, with one sanctioned exception: a pagination cursor, which must be opaque because it encodes a database position.
Walk the decision tree a few times with different pieces of list-view state in mind.
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 the four pillars live in the URL, you’ve made a promise to the user. Name it, because it’s what you test against. Call it the share-and-refresh contract: 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.
The question isn’t “does the filter work,” it’s “does the URL hold the truth.” One litmus test catches every violation:
The contract pins the view parameters, not a frozen photograph of the rows. If a teammate marks an invoice paid between you sharing the link and them opening it, they see current data under the same filter: overdue, sorted by amount, page two of whatever now matches. That’s the point: a shared link is a saved question, not a saved answer. (Cursor pagination complicates this, since a cursor points at a position rather than a snapshot, but that’s the pagination lesson’s concern.)
One boundary: a shared URL to something the recipient can’t 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 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”The split is clean: the server reads the URL, the client writes it.
- The server reads. The page is a Server Component. It reads
searchParams, validates and parses them, runs the database query, and renders the table with real rows already in it. - The client writes. The controls (filter dropdown, sort header, search box, pagination buttons) are Client Components. Each receives the current parsed value as a prop from the server, and on change writes the new value into the URL. That URL change re-renders the page on the server, and the fresh table streams back.
The data round-trips through the URL and the server, never through a client-side fetch.
Two rules keep this architecture intact:
- No
useEffectsyncing state to the URL. The URL is the state; the control reads and writes it directly, so there is no second copy to sync. - No client-side data fetch. The server already has the parsed parameters, so it queries and renders. There is no
useEffect(() => fetch(...)), and so no waterfall where the page loads, then the data loads, then it pops in.
Scrub the phases below to watch a request for /invoices?status=overdue move through the system: 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 finished on the server.
Now the write half. When a control changes the URL, it chooses push or replace, the choice from Navigation hooks. List state uses replace, with { scroll: false }. Every filter tweak, sort flip, and page step reconfigures the same screen, not a new destination; if each pushed a history entry, a user who clicked five filters would press back five times just to leave the page. replace swaps the current entry in place, so back leaves the list instead of rewinding chips one at a time, and { scroll: false } keeps a long list from jumping to the top on every change.
push is reserved for real navigation. Clicking a row to open its detail page is a new destination you’d want back to return from, so that gets push. The rule: reconfiguring the current view replaces, 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”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 are fine.
A list view has four, and the hand-rolled approach degrades visibly as they pile up.
'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, every line. Read the current params, clone them, set one key, replace.
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. One generic handler sets its own key without trampling the siblings and strips empties to keep the URL short, on every change.
The server grows to match: one Zod parse becomes 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);Each parameter is another place to forget a default, forget validation, or trample a sibling key, which is exactly what a library should own.
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.
URLSearchParams is the right primitive for one or two parameters; 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”nuqs erases the friction the four-parameter tab just showed.
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 validates-or-defaults by design, so garbage from the URL falls back instead of reaching your query.
Defaults that strip themselves. Attach a default with .withDefault(...), and when a parameter equals its default, nuqs removes it from the URL, no by-hand delete. The empty URL /invoices becomes the home view, and the address only shows what differs from the baseline. Pick defaults that match the most common view, so the cleanest URL is also the most useful.
One shared definition. The same parser objects feed both sides: 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.
Setup takes three moves.
Move 1: the adapter, once
Section titled “Move 1: the adapter, once”nuqs needs one piece of plumbing at the app root: an adapter that teaches it how this framework’s router works. Wrap the root layout’s children in it once.
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> );}Move 2: the shared parser module
Section titled “Move 2: the shared parser module”Define every parameter’s parser in one module beside the page, app/invoices/searchParams.ts, and build the server-side cache from those same parsers. Later lessons import these parsers for the client controls; the server reads through this 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.
You also get type safety. Because statusParser is parseAsStringEnum([...]).withDefault(null), the parsed value isn’t a vague string | undefined; it’s the exact union the parser declares. Hover it and the type proves it:
const { status, sort, q, cursor } = await searchParamsCache.parse(props.searchParams);The parsers are your validation layer, and you need one because searchParams is user-controlled: anyone can type anything into the address bar. Reach for hand-written Zod only when a parameter is structured beyond the built-in parsers, 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: await the cache’s parse of searchParams, and it 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} />;}From there it’s ordinary code: pass the slices to the query function, pass the current values down to the controls. The query is tenant-scoped to the current organization, but that’s the auth machinery from earlier, not something the URL touches.
The architecture in three files
Section titled “The architecture in three files”The whole architecture as a skeleton: the page, the shared parser module, and one fully-built client control. The other three controls are stubs, one per later lesson.
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)
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. Page and control read 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. Each control takes its server-derived value as a prop; the table is server-rendered with data.
'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), re-rendering the server and streaming a fresh table. No useEffect, no fetch.
These three files share one shape: 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.
Two checks on this lesson’s load-bearing decisions:
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 the rest of the chapter builds
Section titled “What the rest of the chapter builds”Three lessons fill the stubbed controls:
<SortControl />and the real filter shapes — single-select, multi-select, and ranges — plus the invariant that changing a filter or the sort resets pagination.<SearchInput />, with the typed-vs-committed split this lesson flagged.<Pagination />, cursor by default.
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.