Skip to content
Chapter 60Lesson 2

Filter shapes and sort encoding

Encode a list view's filters and sort as URL state with nuqs parsers, building a catalog of filter shapes and a safe sort enum.

The invoices screen from last lesson has one control: a status dropdown. That was enough to settle the architecture, but it isn’t a real list view yet. The people who use it all day want to narrow to several tags at once, billing and urgent together; to see only the invoices created in the first quarter; to toggle archived rows back into view; and to re-order by amount when chasing the biggest debts or by customer when reconciling one account.

That is four new controls, but not four new patterns. List-view anatomy left you with one control built in full, StatusFilter, a single-value enum, plus three stubs in page.tsx and a shared searchParams.ts module that the server and the client both read. Each new control is StatusFilter with one piece swapped: you learn one pattern four times.

By the end you will have a catalog of filter shapes, single enum, multi-value, range, and boolean, plus a built <SortControl /> that encodes sort as a compact -key string. You will also learn the one rule that keeps these controls from corrupting each other’s results.

The status filter you already have has three moving parts. Name them now, because every new shape in this lesson is a variation on the same three:

  • The URL fragment the user sees: ?status=paid.
  • The parser that reads it on the server: parseAsStringEnum(STATUS_VALUES).withDefault(null).
  • The control that writes it on the client: a <select> that reads its current value from a prop and writes through the setter from useQueryState('status', statusParser).
app/invoices/searchParams.ts
// ?status=paid (omitted from the URL when null — the default)
export const statusParser = parseAsStringEnum(STATUS_VALUES).withDefault(null);

URL fragment → parser → control. Each of the next sections fills these same three slots with a different shape: the fragment shifts to match the new data, the parser swaps for one that reads it, and the control stays a thin wrapper over the same contract, value in through a prop and writes out through a setter.

Multi-value filters: many values, one parameter

Section titled “Multi-value filters: many values, one parameter”

The status filter holds one value. A tag filter holds several at once: show me everything tagged billing or urgent. The user sees a single parameter carrying a list:

?tags=billing,urgent

There’s a fork in how you encode that. The web platform’s own convention is to repeat the key, ?tag=billing&tag=urgent, which is what an HTML form with two same-name checkboxes produces. nuqs takes the other road by default: one key, values joined by commas. The parser is parseAsArrayOf(parseAsString).withDefault([]) — wrap any single-value parser in parseAsArrayOf and you get an array parser that splits on commas coming in and joins on commas going out. For a different separator, pass it as the second argument: parseAsArrayOf(parseAsString, ';').

// ?tags=billing,urgent
const tagsParser = parseAsArrayOf(parseAsString).withDefault([]);
setTags(['billing', 'urgent']);

Shorter, readable, and the default. One key, values joined by commas. The address bar stays compact and the intent is legible at a glance.

Default to comma. It’s shorter, it reads cleanly in a shared link, and most of the time no external consumer needs satisfying. Reach for repeated-key only when something on the other end of the wire already expects it.

There’s one real trap. Comma-separation breaks the moment a value can itself contain a comma: if a tag is "billing, Q1", the comma inside it is indistinguishable from the comma between values, so your two-tag filter parses as three. Use comma-separation only where the value space is safe — slugs, IDs, enum-like tags you control. Free text the user types is exactly where this bites; there, pick a separator the values can’t contain.

The control follows the template, just with an array. A multi-select, checkbox list or dropdown, reads its value: string[] from a prop and calls setTags(next) on change — the value still arrives as a prop and the setter still comes from the hook, only the value is a list now. Passing the setter an empty array [] (the default) clears the parameter entirely, so an empty selection produces a clean URL with no tags fragment; the default-stripping from last lesson handles it, no special-casing needed.

Range filters: two parameters, not one blob

Section titled “Range filters: two parameters, not one blob”

A “created between” filter has two ends, a lower bound and an upper bound, and the instinct is to model them as one parameter: a JSON blob, or a custom from..to string. The shape that holds up is two separate parameters:

?createdFrom=2026-01-01&createdTo=2026-03-31

Each bound gets its own parser, parseAsIsoDate with a null default:

app/invoices/searchParams.ts
// ?createdFrom=2026-01-01&createdTo=2026-03-31
export const createdFromParser = parseAsIsoDate.withDefault(null);
export const createdToParser = parseAsIsoDate.withDefault(null);
// later, in the query function (the gte/lte mechanics are out of scope here):
// where(gte(invoices.createdAt, createdFrom), lte(invoices.createdAt, createdTo))

Two parameters win for the same three reasons that drove last lesson’s rule against putting anything in the URL the user can’t read. They are self-describing: createdFrom and createdTo say what they are, a blob says nothing. Each clears independently: the user can drop the upper bound and keep the lower, which a combined parameter can’t express without re-encoding the whole thing. And a blob is brittle: every read and write serializes a custom format, and the first malformed character takes the whole filter down. Two flat parameters carry none of that.

The page hands createdFrom and createdTo to the list query, which turns them into gte and lte predicates: created at or after the lower bound, at or before the upper.

One seam to note and defer: a date range parsed without regard for time zones confuses users in different regions, since “created on January 1st” is a different instant in Auckland than in Los Angeles, and handling it is a later unit’s job.

Boolean toggles and the omitted-default rule

Section titled “Boolean toggles and the omitted-default rule”

The last shape is the simplest. A “show archived” toggle is a single boolean:

?showArchived=true

The parser is parseAsBoolean.withDefault(false). The interesting part is what the default does to the URL.

Because the default is false, the URL carries ?showArchived=true only when the toggle is on. When it’s off, which is the common case and the home view, nuqs strips the parameter and the address stays clean. This is the payoff of picking defaults that match the most common view: the empty URL is the home state, and the address only ever shows what the user changed from the baseline.

app/invoices/searchParams.ts
// ?showArchived=true when on
// (no param) when off — the default is stripped from the URL
export const showArchivedParser = parseAsBoolean.withDefault(false);

Here you’re learning the shape: the parser, the URL behavior, the toggle control. The next chapter wires this same flag into soft delete, hiding archived rows by default and surfacing them when it flips.

Two things to absorb while you’re in boolean territory. First, never write the false value into the URL explicitly: a ?showArchived=false in the address says “the default,” which is the same as saying nothing, and withDefault(false) plus nuqs’ stripping is what prevents it. Second, clearing a parameter differs from leaving it alone: passing the setter null (or the default) removes the parameter, while passing undefined leaves it untouched. You’ll lean on that distinction a couple of sections from now.

Before going further, sort some real requirements into the shapes they map to.

Each list-view requirement needs a filter shape. Drag each into the parser it maps to. Drag each item into the bucket it belongs to, then press Check.

Single enum parseAsStringEnum
Multi-value parseAsArrayOf
Range (two params) parseAsIsoDate ×2
Boolean toggle parseAsBoolean
Show only one status at a time
Pick a single priority level
Filter by any of several tags
Match several assignees at once
Invoices created between two dates
Amount above a minimum and below a maximum
Include archived rows, or not
Toggle “only my invoices”

Sort is the <SortControl /> stub last lesson left for you, and building it forces the most consequential decision in this lesson, one that’s easy to get wrong because the wrong version looks like it works.

Start with the encoding. Sort is a single string that names the column, with a leading minus for descending:

?sort=-total newest, biggest debts first (descending)
?sort=total smallest first (ascending)

The parser is parseAsStringEnum(SORT_VALUES).withDefault('-createdAt'). SORT_VALUES is the as const array from last lesson (createdAt, -createdAt, total, -total), now widened with customer and -customer so users can re-order by account:

app/invoices/searchParams.ts
const SORT_VALUES = [
'createdAt',
'-createdAt',
'total',
'-total',
'customer',
'-customer',
] as const;
export type Sort = (typeof SORT_VALUES)[number];
export const sortParser = parseAsStringEnum(SORT_VALUES).withDefault('-createdAt');

The module now exports both SORT_VALUES and the derived Sort type. Last lesson kept the array private; the control needs it to build its option list and needs Sort to type its prop. The shared module stays the single source of truth. On the server, the query function splits the string on its leading - to get the column and direction, then builds the orderBy clause. That translation is the database chapter’s; here the string is what matters.

The parser could just as easily be parseAsString: accept any sort the URL carries, split it, sort by it. It would pass every test you wrote, and it would be a serious mistake, for two reasons.

First, a free sort string is a shape-injection surface. The sort value isn’t a value in your query, it’s the column being ordered on, which is structural. Your database driver parameterizes values to prevent injection, but it can’t parameterize which column to sort by, because that’s part of the query’s shape, not its data. A free string lets a hand-typed ?sort=passwordHash reach the query planner and order your invoices by a column that should never be sortable. The enum closes that door: only the listed literals are accepted, anything else falls back to the default.

Second, a free sort string is a performance cliff. Sorting on an unindexed column forces the database to pull the entire matching result set into memory and sort it there. On a hundred rows you’d never notice; on fifty thousand, the query that was instant in development times out in production. The enum is gated by the indexes that exist. Every value in SORT_VALUES has a composite index shaped (orgId, sortKey, id): org scope first so the tenant’s rows are grouped, sort key next so the order is precomputed, id last as a stable tiebreaker. With that shape the query plan stays a clean index scan however deep the result set goes. You met the index shape in the database chapter; the rule here is the pairing.

Direction lives inside the string, as -total, rather than as a separate sortKey=total&sortDir=desc pair: one parameter instead of two, fewer bytes in the URL, and a single enum can enumerate exactly the valid key-and-direction combinations rather than letting any key pair with any direction.

The control’s shape is a UX call with two reasonable answers. Clickable column headers, click “Amount” to sort, click again to flip, with up/down chevrons for state, are dense and feel native in a table. A dropdown listing the options by name is more discoverable and works for layouts without column headers, like card grids. We’ll build the dropdown: it’s the simpler artifact, and it mirrors the <select>-based StatusFilter you already have.

Here’s the control, walked through one piece at a time.

'use client';
import { useQueryState } from 'nuqs';
import { type Sort, SORT_VALUES, sortParser } from '../searchParams';
const SORT_LABELS: Record<Sort, string> = {
'-createdAt': 'Newest first',
createdAt: 'Oldest first',
'-total': 'Amount: high to low',
total: 'Amount: low to high',
'-customer': 'Customer: Z to A',
customer: 'Customer: A to Z',
};
export const SortControl = ({ value }: { value: Sort }) => {
const [, setSort] = useQueryState('sort', sortParser);
// cursor reset bundled in the next section
const onChange = (next: Sort) => setSort(next);
return (
<select value={value} onChange={(event) => onChange(event.target.value as Sort)}>
{SORT_VALUES.map((option) => (
<option key={option} value={option}>
{SORT_LABELS[option]}
</option>
))}
</select>
);
};

Write, read, and the option list all trace back to the one shared parser module, the same single-source-of-truth discipline as StatusFilter. Sort is the inferred union of the enum.

'use client';
import { useQueryState } from 'nuqs';
import { type Sort, SORT_VALUES, sortParser } from '../searchParams';
const SORT_LABELS: Record<Sort, string> = {
'-createdAt': 'Newest first',
createdAt: 'Oldest first',
'-total': 'Amount: high to low',
total: 'Amount: low to high',
'-customer': 'Customer: Z to A',
customer: 'Customer: A to Z',
};
export const SortControl = ({ value }: { value: Sort }) => {
const [, setSort] = useQueryState('sort', sortParser);
// cursor reset bundled in the next section
const onChange = (next: Sort) => setSort(next);
return (
<select value={value} onChange={(event) => onChange(event.target.value as Sort)}>
{SORT_VALUES.map((option) => (
<option key={option} value={option}>
{SORT_LABELS[option]}
</option>
))}
</select>
);
};

The current sort arrives as a prop from the server, so the page stays the single read-source. The control never reads the URL for the current value.

'use client';
import { useQueryState } from 'nuqs';
import { type Sort, SORT_VALUES, sortParser } from '../searchParams';
const SORT_LABELS: Record<Sort, string> = {
'-createdAt': 'Newest first',
createdAt: 'Oldest first',
'-total': 'Amount: high to low',
total: 'Amount: low to high',
'-customer': 'Customer: Z to A',
customer: 'Customer: A to Z',
};
export const SortControl = ({ value }: { value: Sort }) => {
const [, setSort] = useQueryState('sort', sortParser);
// cursor reset bundled in the next section
const onChange = (next: Sort) => setSort(next);
return (
<select value={value} onChange={(event) => onChange(event.target.value as Sort)}>
{SORT_VALUES.map((option) => (
<option key={option} value={option}>
{SORT_LABELS[option]}
</option>
))}
</select>
);
};

Only the setter comes from the hook, wired to the same sortParser the server reads through. The leading comma discards the current-value slot, since the prop already has it.

'use client';
import { useQueryState } from 'nuqs';
import { type Sort, SORT_VALUES, sortParser } from '../searchParams';
const SORT_LABELS: Record<Sort, string> = {
'-createdAt': 'Newest first',
createdAt: 'Oldest first',
'-total': 'Amount: high to low',
total: 'Amount: low to high',
'-customer': 'Customer: Z to A',
customer: 'Customer: A to Z',
};
export const SortControl = ({ value }: { value: Sort }) => {
const [, setSort] = useQueryState('sort', sortParser);
// cursor reset bundled in the next section
const onChange = (next: Sort) => setSort(next);
return (
<select value={value} onChange={(event) => onChange(event.target.value as Sort)}>
{SORT_VALUES.map((option) => (
<option key={option} value={option}>
{SORT_LABELS[option]}
</option>
))}
</select>
);
};

On change, write the new sort. This is deliberately incomplete: the comment marks where the real version bundles a cursor reset alongside the sort, built in the next section.

'use client';
import { useQueryState } from 'nuqs';
import { type Sort, SORT_VALUES, sortParser } from '../searchParams';
const SORT_LABELS: Record<Sort, string> = {
'-createdAt': 'Newest first',
createdAt: 'Oldest first',
'-total': 'Amount: high to low',
total: 'Amount: low to high',
'-customer': 'Customer: Z to A',
customer: 'Customer: A to Z',
};
export const SortControl = ({ value }: { value: Sort }) => {
const [, setSort] = useQueryState('sort', sortParser);
// cursor reset bundled in the next section
const onChange = (next: Sort) => setSort(next);
return (
<select value={value} onChange={(event) => onChange(event.target.value as Sort)}>
{SORT_VALUES.map((option) => (
<option key={option} value={option}>
{SORT_LABELS[option]}
</option>
))}
</select>
);
};

The option list renders straight from SORT_VALUES, so the enum is the single source for the dropdown too: add a value to the array and it appears in the UI.

1 / 1

The enum pays off in the types. Because sortParser is a parseAsStringEnum over SORT_VALUES, the value the server hands you isn’t a vague string, it’s the exact union of allowed sorts. Hover it and the constraint is in the type:

const { sort } = await searchParamsCache.parse(props.searchParams);

There is no code path where sort is a column you didn’t index: the parser rejected anything outside the enum before it reached you.

Writing one parameter without trampling the others

Section titled “Writing one parameter without trampling the others”

Until now each control wrote exactly one parameter, and useQueryState('status', statusParser) fit perfectly: one hook, one key. But the list view now holds a crowd of parameters in the URL together: status, tags, createdFrom, createdTo, showArchived, sort, plus the q and cursor that later lessons add. With siblings present, a new question appears: when one control writes its parameter, what happens to the others?

Last lesson’s hand-rolled answer started to creak. To change one key by hand, you clone the current URLSearchParams, set your one key, and serialize the whole thing back, carrying every other parameter along untouched. It works, but it’s exactly the bookkeeping nuqs exists to erase, and one forgotten clone wipes a sibling.

The nuqs answer is useQueryStates, plural. Where useQueryState manages one key, useQueryStates(parsers) takes an object of parsers and returns a single merge-setter. Call it with a partial object and it updates only the keys you name, leaving every other parameter as it was:

const [, setQuery] = useQueryStates({
status: statusParser,
sort: sortParser,
cursor: cursorParser,
});
setQuery({ status: 'paid' }); // only status changes; sort and cursor untouched
setQuery({ status: null }); // status cleared from the URL; the rest intact

That second call turns on a distinction that is small to write and easy to get wrong. Inside a nuqs setter object, the value you pass a key has three meanings:

  • A real value (status: 'paid') sets the parameter.
  • null clears the parameter: it removes the key from the URL and falls back to the default.
  • undefined leaves the parameter untouched: a no-op for that key, as if you hadn’t named it at all.

So setQuery({ status: null }) removes status from the URL, while setQuery({ status: undefined }) does nothing to status. That null-versus-undefined line is load-bearing: it separates a “clear this filter” button that works from one that silently does nothing, and it’s the mechanism the next section’s reset rule is built on.

Fill in each setter argument: which value clears a parameter, which leaves it untouched? Pick the right option from each dropdown, then press Check.

// The user clicks the ✕ on the status chip — clear the status filter entirely:
setQuery({ status: ___ });
// Change the sort, but leave the status filter exactly as it is:
setQuery({ sort: '-total', status: ___ });

The reset invariant: changing what’s shown clears the page

Section titled “The reset invariant: changing what’s shown clears the page”

This is the canonical bug of URL-state list views: the one that ships looking fine, then produces nonsense the first time a user combines two actions. It recurs in the next two lessons, so learn it by name here.

The cursor parameter encodes a position in the current result set: it points at a specific row’s place in this exact ordering and this exact filter. (The next lesson opens the cursor up; for now, “a bookmark into this specific ordered, filtered list” is all you need.) The bookmark only means anything against the list it was made for. Change the sort, and a cursor that encoded “the spot after the row sorted by date” gets decoded against a list sorted by amount: it now points at a meaningless spot, so some rows repeat, some get skipped, and the user pages through results that don’t add up. Shrinking the filter breaks it the same way: the old cursor may point past the end of the new, smaller result set, landing the user on an empty page.

The cursor bookmarked a position in the date ordering. Under the amount ordering that same bookmark points nowhere meaningful: rows repeat, rows vanish. That’s why every change to the result set must clear the cursor in the same write: setQuery({ sort: '-total', cursor: null }).

The fix is structural, not a matter of remembering. You don’t fix this by being careful to clear the cursor whenever you change the sort; you make it impossible to change the sort without clearing the cursor, by baking cursor: null into the same write. This is where useQueryStates and its null-clears semantics pay off. The real onChange for <SortControl />, completing the one we left half-built, is a single atomic write that updates the sort and resets the page together:

const [, setQuery] = useQueryStates({ sort: sortParser, cursor: cursorParser });
const onChange = (next: Sort) => {
// one atomic write: change the sort AND reset the page
setQuery({ sort: next, cursor: null });
};

The same bundle generalizes to every parameter that changes the result set. Changing a filter resets the page too: setQuery({ status: 'paid', cursor: null }). The next lesson applies it to the search term, the one after to pagination’s own edges. It’s the same move every time, which is why it has a name:

A user’s URL is ?sort=-createdAt&cursor=abc123. They click the “Amount, high to low” sort option. What must that click write for the next page to still make sense?

setQuery({ sort: '-total', cursor: null });
setQuery({ sort: '-total' });
router.push('/invoices?sort=-total');
setQuery({ sort: '-total', cursor: undefined });

You’ve built the controls. The last piece lets the user see what’s filtered and undo any of it: a row of active-filter chips above the table, one pill per active filter, each with a ✕ to remove it.

Each chip shows one active filter; its ✕ clears that one, while “Clear filters” clears them all. Both go through the setter, never a fresh navigation.

Where does a chip get its label, like “Paid” or “Billing, Urgent”? From the parsed server-side state passed down as props, the same way each control takes its current value. The server already parsed status, tags, and the rest at the top of the page; handing those values to the chip row renders immediately without waiting on hydration and keeps the server as the single read-source. The only client part of a chip is its ✕, because clearing needs the setter.

A chip’s ✕ clears its one filter through that setter. The reset invariant applies, because dropping a filter changes the result set, so the status chip’s ✕ calls setQuery({ status: null, cursor: null }): drop the filter, reset the page, one atomic write.

Clear-all is one button that empties every filter at once, in a single setter call:

// the status chip's ✕
setQuery({ status: null, cursor: null });

One filter, gone. The ✕ clears its own parameter and resets the page.

Each parameter gets its default: null for the enum and dates, [] for the array, false for the boolean, '' for the search string. Since nuqs strips every value that equals its default, the result is the canonical empty URL, /invoices, exactly where a fresh visit would land.

Don’t clear filters by navigating to /invoices with router.push. The destination is the same, but push adds a history entry, so the back button undoes the clear instead of leaving the page, and it triggers a full segment re-render instead of a quiet parameter swap. The setter replaces by default and only touches the parameters, which is why you reached for nuqs in the first place. Reconfiguring the current view always goes through the setter; push stays reserved for genuine navigation.

If you want to go deeper, these pages are worth a bookmark.