Filters
Single-select
Section titled “Single-select”param.choice gives you a union type and a distinct issue code for an unrecognized value.
const products = defineQueryModel({ status: param.choice(["all", "active", "archived"]).default("all"), page: param.integer({ min: 1 }).default(1),});Making "all" the default is what keeps the URL clean: selecting “All” removes the key rather than
writing ?status=all.
// current URL: /products?status=active&page=4await runtime.transaction((draft) => { draft.status = "archived"; draft.page = 1;});// written URL: /products?status=archivedHistory1 / 1
- history.pushState
- history.replaceState
- popstate
The adapter writes through the History API and re-reads on popstate.
Type a query, press Enter.
Typed state
{
"page": 1,
"sort": "created_at",
"status": "all"
}Canonical URL
Valid/productsMulti-select
Section titled “Multi-select”param.list consumes every value stored under its key, so repeated keys become an array.
const products = defineQueryModel({ tags: param.list(param.text()).default([]),});
products.decode("?tags=vue&tags=nuxt"); // ok, value: { tags: ["vue", "nuxt"] }products.encode({ tags: ["vue", "nuxt"] }); // [["tags", "vue"], ["tags", "nuxt"]]products.encode({ tags: [] }); // [] — equals the default; otherwise it would be [["tags", ""]]Toggling one value:
async function toggleTag(tag: string): Promise<void> { await runtime.transaction((draft) => { draft.tags = draft.tags.includes(tag) ? draft.tags.filter((entry) => entry !== tag) : [...draft.tags, tag]; draft.page = 1; });}Assign a new array rather than mutating in place. The draft’s arrays are copies, so mutation would
work — but treating them as immutable keeps the intent obvious and survives a refactor to replace.
Constraining a list
Section titled “Constraining a list”const products = defineQueryModel({ category: param.list(param.choice(["books", "games", "music", "software"]), { minItems: 1, maxItems: 3, }),});
const accepted = products.decode("?category=books&category=music");accepted.ok; // trueconst rejected = products.decode("?category=books&category=games&category=music&category=software");rejected.issues[0]?.code; // "out_of_range" — four selections exceed maxItemsItem-level validation runs per entry, and item issues carry their index in path. Count violations
report out_of_range against the list itself.
Clearing everything
Section titled “Clearing everything”await runtime.reset(); // every managed key back to its defaultawait runtime.reset(["status", "tags"]); // just theseawait runtime.remove(["status", "tags"]); // omit the keys entirelyFor parameters with defaults the three produce the same URL. Prefer reset when the intent is
“back to the initial view” — it reads correctly even for parameters you add later.
Boolean toggles
Section titled “Boolean toggles”const products = defineQueryModel({ inStock: param.boolean().default(false),});
const result = products.decode("?inStock=yes");if (result.ok) result.value.inStock; // trueproducts.encode({ inStock: true }); // [["inStock", "true"]]Decoding accepts true, 1, yes, on and their negatives, case-insensitively. Encoding writes
the first entry of the truthy or falsy list, so the output is always ?inStock=true rather than
whichever spelling arrived.
A valueless flag — ?inStock with no = — is not supported as true. An empty value is
rejected before the boolean codec ever sees it: the parameter reports empty and recovers to its
default. Custom truthy entries cannot change that, because the emptiness check happens first.
Write ?inStock=true, which is what encoding produces anyway.
Distinguishing “cleared” from “untouched”
Section titled “Distinguishing “cleared” from “untouched””If your application must tell “the user removed this filter” from “the user never set it”, the
default cannot express both. Use .nullable():
const filters = defineQueryModel({ category: param.text().nullable().optional(),});
const untouched = filters.decode("");const cleared = filters.decode("?category=");
if (untouched.ok) untouched.value.category; // undefined — untouchedif (cleared.ok) cleared.value.category; // null — explicitly clearedServer-side filtering
Section titled “Server-side filtering”const result = readRequestQuery(request, products);const values = result.ok ? result.value : { ...products.defaults(), ...result.partial };
const rows = await db.products.findMany({ where: { status: values.status === "all" ? undefined : values.status, tags: values.tags.length > 0 ? { hasSome: values.tags } : undefined, },});values.status is a narrow union here, so the comparison is exhaustive and a new status added to
the model becomes a compile error in this file.
Edge cases
Section titled “Edge cases”- An unknown choice reports
unknown_choiceand recovers to the default — the page renders with “All” rather than breaking on a stale bookmark. - Empty entries in a list (
?tags=&tags=vue) are dropped and reported asempty. - Order within a list is preserved exactly as it appeared in the query.
- A list default of
[]is compared by encoded form, so it is correctly omitted.