Skip to content
Chapter 60Lesson 1

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.

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.

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.

I have a piece of list-view state — where does it live?

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:

  1. New tab: open the URL fresh and you get the same filtered, sorted, paginated view.
  2. Refresh: reload and nothing changes.
  3. Share: paste it into Slack and your coworker sees the same view (assuming they’re allowed to).
  4. 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.

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 useEffect syncing state to the URL. You will not hold the filter in useState and 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.

One request through the list view

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');

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.

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.

app/invoices/page.tsx
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.

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.

app/layout.tsx
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.

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.

1 / 1

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.

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.

app/invoices/page.tsx
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.

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.

app/invoices/searchParams.ts
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.

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.

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.

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.

If you want to go deeper than this lesson, these four are worth a bookmark.