Filter shapes and sort encoding
Encode a list view's filters and sorting as URL state with nuqs parsers, building a reusable catalog of filter shapes and a safe sort enum.
The invoices screen you built last lesson has exactly one control: a status dropdown. That was enough to land the architecture, but it isn’t a real list view yet. The people who work in this screen all day want more. They want to narrow to several tags at once, billing and urgent together. They want only the invoices created in the first quarter. They want a “show archived” toggle to dig up old rows. And they want to re-order by amount when chasing the biggest debts, or by customer when reconciling one account.
That’s four new controls, and at first glance four new problems, but they aren’t. The list-view anatomy left you with one control built in full, StatusFilter, a single-value enum; three stubs commented into page.tsx waiting for their lesson; and a shared searchParams.ts module that both the server and the client read from. Every new control in this lesson is the same shape as StatusFilter with one piece swapped out. You’re not learning four patterns, you’re learning one pattern four times.
By the end you’ll hold a small catalog of filter shapes: single enum, multi-select, range, and boolean. You’ll reach for them by reflex the moment a product spec lands on your desk. You’ll have built the <SortControl /> stub and encoded sort as a compact -key string, and you’ll understand why that string has to be a closed enum rather than a free-text field. That last decision is what separates a list view that scales from one that falls over at fifty thousand rows. You’ll also learn the single rule that keeps all these controls from quietly corrupting each other’s results. None of this is new architecture. The page still reads, validates, queries, and renders; the controls still take their current value as a prop and write back through a setter; one parser module still defines the shape for both sides. You’re just filling in the stubs.
The single enum, as the template
Section titled “The single enum, as the template”Before adding anything, pin down the shape you already have, because every new shape is a variation of it. The status filter has three moving parts. Naming them now gives you a fixed scaffold to hang the rest of the lesson on:
- 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 currentvaluefrom a prop and writes through the setter fromuseQueryState('status', statusParser).
// ?status=paid (omitted from the URL when null — the default)export const statusParser = parseAsStringEnum(STATUS_VALUES).withDefault(null);Hold those three slots in your head, URL fragment → parser → control, because the next three sections each fill the same three slots with a different shape. Watch how little changes each time. The URL fragment shifts to match the new data, the parser swaps for one that reads that data, and the control stays a thin wrapper over the same contract: value as a prop, writes through a setter. Once you see the pattern stay still while the parser moves, you’ve got the whole catalog.
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 shape the user sees is a single parameter carrying a list:
?tags=billing,urgentThat encoding is worth pausing on, because there’s a fork here. The web platform has its own convention for “a parameter with multiple values”: repeat the key, as in ?tag=billing&tag=urgent. That’s what an HTML form with two checkboxes of the same name produces. nuqs takes the other road by default: one key, values joined by commas. The parser is parseAsArrayOf(parseAsString).withDefault([]). Wrap a single-value parser in parseAsArrayOf and you get an array parser that splits on commas coming in and joins on commas going out.
If you ever need a separator other than a comma, it’s the parser’s second argument: parseAsArrayOf(parseAsString, ';') switches to semicolons. It’s a positional argument, not an options object.
So which encoding should you reach for? Compare them side by side.
// ?tags=billing,urgentconst tagsParser = parseAsArrayOf(parseAsString).withDefault([]);
setTags(['billing', 'urgent']);Shorter and readable, and the default. One key, values joined by commas. The address bar stays compact and the intent is legible at a glance. Reach for this unless something downstream forces your hand.
// ?tag=billing&tag=urgentconst tagsParser = parseAsArrayOf(parseAsString).withDefault([]);
setTags(['billing', 'urgent']);The HTML-form convention. Repeating the key is what native multi-checkbox forms emit and what some backends expect. Reach for it only when you’re interoperating with an external system, such as an existing API, that already speaks this dialect.
The rule to build into your instincts is to default to comma. It’s shorter, it reads cleanly in a shared link, and most of the time there’s no external consumer to satisfy. Repeated-key is the orthodox web form, but being orthodox isn’t a reason on its own. Reach for it only when something on the other end of the wire expects it.
There’s one real trap here, worth naming because it’s the kind of bug that passes every test until production data hits it. Comma-separated encoding breaks the moment a value itself can contain a comma. If a tag could be "billing, Q1", the comma inside it is indistinguishable from the comma between values, so your two-tag filter parses as three. The fix is to pick a separator the values can’t contain, or, better, to use comma-separation only when the value space is genuinely safe: slugs, IDs, enum-like tags that you control. Free text the user can type is exactly where this bites.
The control follows the template, just with an array. A multi-select, whether a checkbox list or a multi-select dropdown, reads its value: string[] from a prop and calls setTags(next) on change. The value is a prop and the setter comes from the hook, exactly as before; the value is just a list now instead of a scalar. One detail that pays off later: passing the setter an empty array [] (the default) clears the parameter entirely, so an empty selection produces a clean URL with no tags fragment at all. You don’t special-case “nothing selected”; the default-stripping you met last lesson handles it.
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. The instinct, especially coming from object-shaped thinking, is to model that as one thing, a single parameter holding both dates, maybe a JSON blob, maybe a custom from..to string. Resist it. The shape that holds up is two separate parameters:
?createdFrom=2026-01-01&createdTo=2026-03-31Each bound gets its own parser, parseAsIsoDate with a null default, so you declare createdFromParser and createdToParser independently.
// ?createdFrom=2026-01-01&createdTo=2026-03-31export 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))Why two parameters and not one? There are three reasons, and they’re the same reasons that drove last lesson’s rule about not putting anything in the URL the user couldn’t understand by reading it. First, two named parameters are self-describing: createdFrom and createdTo say exactly what they are, while a blob says nothing. Second, each one 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. Third, a blob is brittle: every read and write has to serialize and parse a custom format, and the first malformed character takes the whole filter down. Two flat parameters have none of that surface.
The parsed values flow straight into the query. 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. You saw the shape in the comment above. The actual Drizzle where mechanics were covered back in the database chapters; here all that matters is that two clean dates come out of the URL and land in the query.
One thing to note and move past, because it has a whole unit of its own later: a date range parsed without thinking about time zones will quietly confuse users in different regions. “Created on January 1st” means a different absolute instant in Auckland than in Los Angeles. The discipline is to treat dates at the URL boundary as UTC and format them in the user’s time zone in the UI. The machinery for that, and the Temporal tools the course standardizes on, are a later unit’s job. For now, just know the seam exists; don’t solve it here.
Boolean toggles and the omitted-default rule
Section titled “Boolean toggles and the omitted-default rule”The last shape is the simplest and teaches the cleanest lesson about URLs. A “show archived” toggle is a single boolean:
?showArchived=trueThe parser is parseAsBoolean.withDefault(false). But the interesting part isn’t the parser, it’s what the default does to the URL, because this is where the default-stripping rule from last lesson stops being abstract and starts earning its keep.
The default is false, so 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 entirely and the address stays clean. The user who never touches the toggle never sees it in their URL. That’s the whole point 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.
// ?showArchived=true when on// (no param) when off — the default is stripped from the URLexport const showArchivedParser = parseAsBoolean.withDefault(false);This shape is doing double duty. It’s the boolean template, and it’s also the exact toggle the next chapter builds on when it wires soft delete: archived rows hidden by default, surfaced when this flag flips. For now you’re learning the shape: the parser, the URL behavior, the toggle control. Wiring showArchived into the database query, so that archived rows appear and disappear, belongs to that next chapter, not here. Build the toggle and leave the query alone.
Two more things to absorb while you’re in boolean territory. First, never write the false value into the URL explicitly. A ?showArchived=false sitting in the address is pure clutter: it says “the default,” which is the same as saying nothing. withDefault(false) plus nuqs’ stripping is exactly what prevents it, so let the default do its job. Second, there’s a meaningful difference between clearing a parameter and leaving it alone, and it first matters on toggles, so it’s worth a preview. Passing the setter null (or the default) removes the parameter; passing it undefined leaves it untouched. You’ll lean on that distinction hard in a couple of sections; for now just notice it exists.
That’s the catalog: four shapes, each a variation on the same URL-fragment-parser-control template. Before going further, sort some real requirements into the shapes they map to. This is the reflex the whole section was building toward.
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.
Sort: one string, a leading minus, and why it must be an enum
Section titled “Sort: one string, a leading minus, and why it must be an enum”Now the centerpiece. Sort is the <SortControl /> stub last lesson left for you, and building it surfaces the most consequential decision in this lesson, one that’s easy to get wrong precisely because the wrong version looks like it works.
Start with the encoding. Sort is a single string parameter. The string names the column, and a leading minus means descending:
?sort=-total newest, biggest debts first (descending)?sort=total smallest first (ascending)The parser is parseAsStringEnum(SORT_VALUES).withDefault('-createdAt'), where SORT_VALUES is the array last lesson declared (createdAt, -createdAt, total, -total), now widened to add customer and -customer so users can re-order by account. This is the same pattern the status filter used, an as const array plus a derived type, just grown by two values:
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');Two changes from last lesson hide in there. The array gained customer/-customer, and the module now exports both SORT_VALUES and a derived Sort type. Last lesson kept the array private, but the control needs the array to build its option list and the Sort type to type its prop. Widening the shared module like this is expected; keeping one source of truth is the whole point. On the server, the query function splits the string on its leading - to decide column and direction, then builds the orderBy, the SQL clause that sets row order. The mechanics of that translation are the database chapter’s; here the string is what matters.
Now the decision. That parser could so easily have been parseAsString: accept any sort string the URL carries, split it, sort by it. It would pass every test you wrote. It is also a serious mistake, for two distinct reasons, and an experienced engineer makes this call without hesitating.
First, a free sort string is a shape-injection surface. The sort value doesn’t end up as a value in your query, it ends up as the column being ordered on. That’s structural. Even though your database driver dutifully parameterizes values to prevent injection, it can’t parameterize which column to sort by, because that’s part of the query’s shape, not its data. So a free string lets a hand-typed ?sort=passwordHash reach the query planner and ask the database to order your invoices by a column that should never be exposed through a sort. The enum closes that door: only the listed literals are accepted, and anything else falls back to the default. This is the same defense you saw last lesson when a hand-edited ?sort=passwordHash had to be stopped at the parser; the enum is the stop.
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, it falls over: the query that was instant in development times out in production. The enum isn’t an arbitrary allow-list. It’s gated by the indexes that actually exist. Every value in SORT_VALUES corresponds to a composite index shaped (orgId, sortKey, id): the org scope first so the tenant’s rows are already grouped, the sort key next so the order is precomputed, the id last as a stable tiebreaker. With that shape in place the query plan stays a clean index scan no matter how deep the result set goes. You met that index shape in the database chapter; here the rule that matters is the pairing.
You might wonder why direction lives inside the string, as -total, rather than as a separate sortKey=total&sortDir=desc pair. The single-string form wins on every axis that matters: it’s one parameter instead of two, it’s 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. Two parameters buy you nothing here, so the course picks the -key string.
A word on the control’s shape, because there are two reasonable choices and the call is pure UX. You can make the column headers clickable: click “Amount” to sort by it, click again to flip direction, with little up/down chevrons showing the state. That’s dense and feels native in a table. Or you can offer a dropdown listing the sort options by name. The dropdown is more discoverable, and it works for layouts that aren’t tables, since card grids, for instance, have no column headers to click. We’ll build the dropdown form for <SortControl />: it’s the simpler artifact, and it mirrors the <select>-based StatusFilter you already have, so the shape will feel familiar.
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 is still 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 is taken 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. Don’t treat this as finished.
'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 is rendered straight from SORT_VALUES, so the enum is the single source for the dropdown too: add a sort value to the array and it appears in the UI automatically.
The payoff of the enum shows up in the types, too. 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 right there in the type:
const { sort } = await searchParamsCache.parse(props.searchParams);The type itself is the guarantee: there is no code path where sort is a column you didn’t index, because the parser already rejected anything outside the enum.
Writing one parameter without trampling the others
Section titled “Writing one parameter without trampling the others”Up to now each control wrote exactly one parameter, and useQueryState('status', statusParser) was the perfect tool: one hook, one key. But the list view now has a crowd of parameters living in the URL together: status, tags, createdFrom, createdTo, showArchived, sort, and the q and cursor that later lessons add. The moment there are siblings, a new question appears: when one control writes its parameter, what happens to the others?
You saw the hand-rolled answer last lesson, and you saw it start to creak. To change one key safely 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. It’s also exactly the bookkeeping nuqs exists to erase, and doing it by hand is one forgotten clone away from wiping a sibling.
The nuqs answer is useQueryStates, plural. Where useQueryState manages one key, useQueryStates(parsers) takes an object of parsers and hands back a single merge-setter. Call it with a partial object and it updates only the keys you name, leaving every other parameter exactly as it was:
const [, setQuery] = useQueryStates({ status: statusParser, sort: sortParser, cursor: cursorParser,});
setQuery({ status: 'paid' }); // only status changes; sort and cursor untouchedsetQuery({ status: null }); // status cleared from the URL; the rest intactThat second call introduces a distinction that is small to write and easy to get wrong, so look at it closely. Inside a nuqs setter object, the value you pass a key has three meanings:
- A real value (
status: 'paid') sets the parameter. nullclears the parameter: it removes the key from the URL and falls back to the default.undefinedleaves the parameter untouched: it’s 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’s the difference between a “clear this filter” button that works and one that silently does nothing, and it’s the mechanism the next section’s reset rule is built on. Lock it in before moving 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 most important new idea in the lesson, and it’s the canonical bug of URL-state list views: the one that ships to production looking fine and then produces nonsense the first time a user combines two actions. It recurs in the next two lessons, so learn it by name here.
State it plainly first, then we’ll see why it’s true:
Here’s the reasoning, and it’s worth working through rather than just reading. 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 cracks the cursor open and shows what’s actually inside it; for now, “a bookmark into this specific ordered, filtered list” is all you need.) The trouble is that the bookmark only means anything against the list it was made for. Change the sort, and the old cursor, which encoded “the spot after the row sorted by date,” gets decoded against a list sorted by amount. It now points at a meaningless spot. Some rows repeat, some get skipped, and the user pages through results that don’t add up. Shrink the filter and it’s the same problem: the old cursor may point past the end of the new, smaller result set, landing the user on an empty or wrong page.
The diagram below makes this concrete. On the left, a list sorted newest-first with the cursor bookmarking a position partway down. On the right, the user has flipped to sort by amount, but the cursor came along unchanged. Watch where it lands.
?sort=-createdAt&cursor=… ?sort=-total&cursor=… cursor unchanged 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 }).
Now the fix, and the important thing about it is that it’s structural, not a matter of remembering. You don’t fix this by being careful to clear the cursor whenever you change a sort. You fix it by making it impossible to change the sort without clearing the cursor, by baking cursor: null into the same write. This is why the last section introduced useQueryStates and the null-clears semantics: they pay off right here. The real onChange for <SortControl />, completing the one we deliberately 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, and the one after that applies it to pagination’s own edges. It’s the same move every time, which is why it has a name:
One check before moving on, not on recall but on the reasoning. Make the call this section was built to train.
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 });abc123 bookmarks a spot in the date ordering; decode it against an amount ordering and it lands nowhere meaningful — rows repeat, rows vanish. So the new sort and the cursor reset have to land in the same write, with cursor: null to actually drop the parameter. cursor: undefined is the trap: it leaves the stale cursor untouched, so the bug ships anyway. And nuqs never clears the cursor for you — the reset is your job. Reaching for router.push is worse still: it adds a history entry and re-renders the whole segment when a replace-style setter would only swap the params.Showing and undoing filters: chips and clear-all
Section titled “Showing and undoing filters: chips and clear-all”You’ve built the controls. The last piece is the affordance that makes them usable: a way for the user to see what’s currently filtered and undo any of it. This part pulls together everything the lesson assembled.
The pattern is a row of active-filter chips above the table, one small pill per active filter, each with a ✕ to remove it:
Each chip reflects one active filter, and its ✕ clears just that one; “Clear filters” resets them all. Both go through the setter, never a fresh navigation.
Each chip’s ✕ clears its one filter through the setter, and the reset invariant applies here too, because clearing 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.
Now the architectural point, which is a direct application of last lesson’s discipline. Where does a chip get the data it displays, like “Paid” or “Billing, Urgent”? Not from a second client-side read. The chip list renders from the parsed server-side state passed down as props, exactly the way the controls take their current value as a prop. The server already parsed status, tags, and the rest at the top of the page. Handing those values to the chip row as props is simpler than reading them again on the client, it renders immediately without waiting on hydration, and it keeps the server as the single read-source. The only client part of a chip is its ✕ button, because clearing needs the setter. So the division is clean: the chip’s label and visibility come from a prop, and the chip’s ✕ is the lone interactive bit.
Then there’s clear-all, one button that empties every filter at once. It’s a single setter call that resets every filter parameter to its default and clears the cursor:
// the status chip's ✕setQuery({ status: null, cursor: null });One filter, gone. A chip’s ✕ clears its own parameter and resets the page. The reset invariant applies, because dropping a filter changes the result set.
// the "Clear filters" buttonsetQuery({ status: null, tags: [], createdFrom: null, createdTo: null, showArchived: false, q: '', cursor: null,});Every filter, reset together. One write sets each parameter back to its default; nuqs strips all those defaults from the URL, returning the user to the clean home view. Same setter, same cursor reset, just all the keys at once.
Notice that clear-all passes each parameter its default: null for the enum and dates, [] for the array, false for the boolean, '' for the search string. nuqs strips every value that equals its default, so the result is the canonical empty URL: /invoices, the home view. The user lands exactly where a fresh visit would put them.
There’s a tempting shortcut here that you should refuse: clearing filters by navigating to /invoices with router.push. It looks like it would work, since the destination is the same. But it pushes a history entry, so the back button now undoes the clear instead of leaving the page, and it triggers a full segment re-render rather than a quiet parameter swap. The setter is the right tool: it replaces by default and only touches the parameters, which is the whole reason you reached for nuqs in the first place. Reconfiguring the current view always goes through the setter; push stays reserved for genuine navigation.
What this lesson built, and what comes next
Section titled “What this lesson built, and what comes next”You came in with one control and left with a catalog. Pull the threads together:
- Four filter shapes, each a variation on the same URL-fragment → parser → control template: the single enum (
parseAsStringEnum), the multi-value array (parseAsArrayOf, comma by default), the range as two independent parameters (parseAsIsoDatetwice), and the boolean toggle whosefalsedefault keeps it out of the URL until it’s flipped on. - Sort as a
-keyenum string, gated by the composite indexes that exist: a leading minus for descending, a closed set rather than a free string, and a new sort option that ships with its index migration. useQueryStates’ merge-setter writes one parameter without trampling its siblings, wherenullclears a parameter andundefinedleaves it untouched.- The reset invariant: every write that changes what’s shown bundles
cursor: nullinto the same setter call. This is the chapter’s recurring rule, and the next two lessons lean on it by name. - Chips and clear-all read from server-derived props and write through the setter, never
router.push, with the chips’ ✕ buttons the only client part of an otherwise server-rendered row.
And the acceptance test is the one from last lesson, now pointed at the filter row you just built: if a click changes the result but not the URL, the contract is broken. Flip a filter, copy the address, open it in a new tab, and you should land on the exact same view. Run that against every control here; it’s the test that catches the bugs the eye misses.
What’s still stubbed gets built next. The next lesson builds <SearchInput /> and the split between the text a user is typing and the term they’ve committed, and it applies this lesson’s reset invariant to the search term, so a new search clears the page just like a new filter does. The lesson after that builds <Pagination />, makes the cursor-by-default call, and finally cracks open the opaque cursor this lesson treated as a black box. Same searchParams.ts, same page shape. Keep filling the stubs.
External resources
Section titled “External resources”If you want to go deeper, these pages are worth a bookmark.
The merge-setter and batched writes across multiple parameters — the mechanism behind this lesson's reset invariant.
parseAsArrayOf, parseAsIsoDate, parseAsBoolean, parseAsStringEnum, and the separator argument for array parsers.
Why a sort needs a matching index to stay fast — the database reasoning behind gating the sort enum on the indexes that exist.
A focused course lesson on the composite-index column order that lets ORDER BY skip the sort step entirely.