Filters in the URL

One readable query param per active filter, decoded defensively.

A filtered view worth sharing lives in the URL. encodeFilterValues turns the record into one short, readable param per active filter; decodeFilterValues reads it back against the screen's declarations.

?status=failed,bounced&customer=!cus_acme~Acme%20Corporation
import {
	decodeFilterValues,
	encodeFilterValues,
	RESERVED_LIST_PARAMS,
} from "@voila.dev/ui/filter";
 
const values = decodeFilterValues(definitions, searchParams);
 
function setValues(next: FilterValues) {
	navigate({
		search: (previous) => ({
			...withoutFilterKeys(previous, definitions),
			...encodeFilterValues(definitions, next),
		}),
	});
}
kindparam
textparis, !paris (excluded)
number30
numberRange, moneyRange10..20, 10.., ..20 (money in minor units)
selectpaid,refunded, !failed (the whole list excluded)
dateRange2026-01-01..2026-06-30, open bounds allowed
booleantrue, false
geoRadius30@47.218,-1.553@Nantes
entityid~label list, ! excludes; labels escaped

The definition, not the param, decides how a value is read, so a stale bookmark degrades to fewer filters and never to a broken screen: unknown keys, options the select no longer offers, unreadable ranges and a filter that changed kind are all dropped. Routers that JSON-parse their query strings (TanStack Router does) hand 30 back as a number and true as a boolean; the decoder takes those too.

Two constants keep the codec and your list state in agreement. RESERVED_LIST_PARAMS (q, sort, dir, page) are the list's own params — a filter key colliding with one is skipped and reported in the console. CLEARED_FILTER_SENTINEL (~) is what you write under a key to say "cleared" when the screen opens on default filters, where an absent key already means "use the default"; the decoder ignores it.