Skip to content
Chapter 60Lesson 1

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.

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.

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.

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

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.

  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.

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.

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 useEffect syncing 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.

One request through the list view

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

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.

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.

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

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.

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.

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

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.

1 / 1

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.

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.

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} />;
}

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 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)
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. Page and control read these exact parsers, so the shape is declared once.

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.

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.

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.

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