URL state
How the search, filter, sort and page are written to the URL, and how to connect it to your router.
Everything that decides which rows users see lives in the URL:
/orders?q=nguyen&status__eq=paid&amount__between=10,50&sort=-amount,customer&page=2&per_page=50| Param | Written by | Meaning |
|---|---|---|
q=nguyen | the search box | the search text |
status__eq=paid | the filter | one param per rule: field__operator=value |
sort=-amount,customer | header clicks | column ids, most important first; - means descending |
page=2 | the pagination | the page, counted from 1 |
per_page=50 | the rows-per-page picker | the page size |
Defaults aren't written: page 1, the default page size and the default sort leave no param, so a fresh table has a clean URL. Links can be shared, reloads keep the view, and Back / Forward work.
Connect to your router
An adapter reads and writes the URL through your router. You pass it to the FilterProvider, and the table uses the same one:
| Adapter | Import | Use it for |
|---|---|---|
<NextFilterProvider> or useNextAdapter() | @querycn/filter-next | Next.js App Router |
useReactRouterAdapter() | @querycn/filter-react/react-router | react-router 6.4+ and 7 |
useBrowserUrlAdapter() | @querycn/filter-react | Vite and other apps without a client router |
| none | State stays in memory, the URL is left alone. Good for dialogs and tests. | |
createMemoryAdapter("status__eq=paid") | @querycn/filter-react | In memory, with a starting query string |
Without a FilterProvider, pass the adapter to useDataTable({ adapter }) instead.
All URL adapters replace the current history entry instead of adding one, so ten sorts don't add ten Back steps. They keep the path, the hash and your other query params.
Back to page 1
Page 4 of the old results means nothing for new ones, so every change that reshapes the results starts again at page 1:
- Sort, page size or search: the table does it itself.
- Filter: the filter doesn't know about pages, so tell it with
onApply:
<FilterProvider fields={orderFields} adapter={adapter} onApply={() => resetPagePatch()}>onApply returns params to write together with the filter, so the filter and page change in one URL update. resetPagePatch() returns { page: null } (null removes a param), using your page param name if you renamed it. It also covers Clear filters and the chips' ×.
Table options
Pass these as url to useDataTable:
import type { TableUrlOptions } from "@querycn/table-react"
const url = {
params: { sort: "order", page: "p", perPage: "size", search: "search" },
pageSizes: [25, 50, 100],
defaultPageSize: 25,
defaultSorting: [{ id: "createdAt", desc: true }],
} satisfies TableUrlOptions
useDataTable({ data, columns, getRowId, url })Define it outside your component, like columns. In server mode, pass the same object to useTableQuery and parseTableParams, so they all read the URL alike.
Prop
Type
Already using ?q=?
If your app puts something else in q (a site-wide search box, say), the table would read it as its search. Rename the table's param: url: { params: { search: "search" } }.
Broken links
A URL is user input, so reading it never throws. Whatever doesn't make sense is dropped:
sortcolumns that don't exist, can't sort or are repeated;- a
pagethat isn't a positive whole number (reads as page 1); - a
per_pageoutsidepageSizes(reads as the default); - a
qlonger than 200 characters (cut); - filter rules with an unknown field, an operator the field doesn't offer, or a value of the wrong shape. Each rule is checked on its own, so one bad rule doesn't drop the others.
A page past the last one shows the last one. In server mode, the URL is also rewritten to that page once the backend's total is known.
The filter's params in detail
Each applied rule is one param, field__operator=value:
?status__eq=paid&amount__between=10,50&tags__in=urgent,vip&deletedAt__isEmpty- Ranges and lists are comma-separated:
amount__between=10,50,tags__in=urgent,vip. - Operators without a value write only the key:
deletedAt__isEmpty. - OR adds
join=or. AND is the default and adds nothing. - Two rules on the same field and operator repeat the key:
status__ne=draft&status__ne=void. - Field names may contain
__too: the key is split at the last one. - A comma inside a list item is written
%252C, so it isn't read as a separator. - Only what a URL can't hold is escaped: spaces,
&,=,#,+,%and non-ASCII. Characters like,:/@stay readable.
When a filter is applied, every param that belongs to the filter is replaced: join, and any field__… key of a declared field. Other params are left alone.
Reading the filter outside React
The provider and the table do this for you. In a backend, a script or a test, use the functions directly:
import { decodeFilters, encodeFilters } from "@querycn/filter-core"
const context = { fields: orderFields }
decodeFilters("status__eq=paid&nope__eq=1&amount__between=1&page=2", context)
// → { join: "and", rules: [{ id: "u0", field: "status", operator: "eq", value: "paid" }] }
encodeFilters(
{ join: "and", rules: [{ id: "a", field: "amount", operator: "between", value: ["10", "50"] }] },
context
)
// → "amount__between=10,50"decodeFilters takes a query string (with or without ?) or a URLSearchParams. Decoded rules get ids by position (u0, u1…), so the server and the client decode a URL to the same state. In Next.js, parseFilters from @querycn/filter-next/server does the same with a page's searchParams.
The table's params have the same pair of functions (also exported from @querycn/table-react/server):
import { decodeTableParams, encodeTableParams } from "@querycn/table-react"
decodeTableParams("sort=-amount,amount,nope&page=0&per_page=7", {
sortableColumns: ["amount", "customer"],
})
// → { sorting: [{ id: "amount", desc: true }], pagination: { pageIndex: 0, pageSize: 20 }, search: "" }
encodeTableParams({ sorting: [{ id: "amount", desc: true }], pagination: { pageIndex: 1, pageSize: 20 }, search: "" })
// → { sort: "-amount", page: "2", per_page: null, q: null } (null removes the param)With your own TanStack table instead of useDataTable, useTableUrlState({ adapter, …options }) gives the URL state as controlled props: sorting, pagination, search and their on…Change handlers.
Advanced
A custom filter URL format
To match a URL scheme you already have, pass a urlFormat to the provider, and the same one to parseFilters or decodeFilters on the server. A format maps one rule to one param and back. This one writes _customer=["like","%acme%"]:
import type { UrlFormat } from "@querycn/filter-core"
const toUrlOperator: Record<string, string> = { contains: "like", eq: "eq", in: "in" }
const fromUrlOperator = Object.fromEntries(
Object.entries(toUrlOperator).map(([ours, theirs]) => [theirs, ours])
)
export const legacyUrlFormat: UrlFormat = {
encodeRule: ({ field, operator, value }) => [
`_${field}`,
JSON.stringify(
operator === "contains" ? ["like", `%${value}%`] : [toUrlOperator[operator] ?? operator, value]
),
],
decodeRule: (key, value) => {
if (!key.startsWith("_")) return null // not a filter param
const [operator, raw] = JSON.parse(value) // a throw drops the param
return {
field: key.slice(1),
operator: fromUrlOperator[operator] ?? operator,
value: operator === "like" ? String(raw).replace(/^%|%$/g, "") : raw,
}
},
joinParam: "_join",
}encodeRuleonly gets complete rules.valueisnullfor operators without one, and an array for ranges and lists.decodeRulereturnsnullfor params that aren't rules, likepage. What it returns is checked like any other rule, so raw text is fine:"5"becomes5on a number field.- OR is written as
joinParam=or(defaultjoin).
A custom adapter
An adapter is a small object that holds a query string. This one keeps the filter and table state in localStorage instead of the URL:
import { applyParamChanges, type UrlStateAdapter } from "@querycn/filter-react"
export function createStorageAdapter(key: string): UrlStateAdapter {
const listeners = new Set<() => void>()
const read = () => localStorage.getItem(key) ?? ""
return {
read,
write: (changes) => {
const next = applyParamChanges(read(), changes)
if (next === null) return // nothing changed
localStorage.setItem(key, next)
listeners.forEach((listener) => listener())
},
subscribe: (onChange) => {
// `storage` fires for changes made in other tabs
const onStorage = (event: StorageEvent) => event.key === key && onChange()
listeners.add(onChange)
window.addEventListener("storage", onStorage)
return () => {
listeners.delete(onChange)
window.removeEventListener("storage", onStorage)
}
},
readServer: () => "",
}
}Create it once (module scope or useState(() => …)), not on every render. Its methods are called without this.
Prop
Type
Router notes
- Plain browser adapter: it only sees the URL in the browser, so server rendering and hydration see an empty state, then the real one right after. The Next.js and react-router adapters read the URL through the router, so their server render already has it. Don't use it next to a client-side router: the router's navigations aren't observed.
- react-router +
<ScrollRestoration>: if the URL has a hash, every apply scrolls to its target. UsegetKeybased on the pathname, or avoid hashes on these pages. - react-router +
useBlocker: when a blocker stops the navigation, the value just written waits until the URL changes again. - Two filters on one page share the query string, so give their fields different names.