What it solves #
A user picks status = active, sorts by date, opens page 3 — and shares the link with a teammate. You want that link to open with the exact same filters already applied. No bespoke serialization, no manual re-hydration.
That's what an adapter does: bridges the
builder to an external source (URL, localStorage,
anything). The built-in createSearchParamsAdapter
handles the URL case.
30-second example #
Pass an adapter, set sync: true, and you're done.
import {
useQueryBuilder,
createSearchParamsAdapter,
} from "@cgarciagarcia/react-query-builder"
const builder = useQueryBuilder({
adapter: createSearchParamsAdapter({ sync: true }),
})
builder.filter("status", "active").sort("created_at", "desc")
// URL bar: /?filter[status]=active&sort=-created_at
What just happened:
- On mount, the adapter read the URL and seeded the builder.
sync: truethen writes once right after mount to normalise the URL against the final state (defaults,urlOmit, a stale link — no mutation required), and on every subsequent change.- The URL output mirrors what
.build()emits, so backend and URL bar agree.
Read-only mode #
Only want to hydrate on mount and never touch the URL after? Just leave
sync out.
useQueryBuilder({
adapter: createSearchParamsAdapter(),
})
// `read()` runs once at mount — same semantics as `useState(() => …)`.
// URL changes after mount do NOT re-hydrate.
Two-way binding #
sync accepts three forms depending on how
aggressive the URL updates should be.
// 1) Default: replaceState — no extra history entries
createSearchParamsAdapter({ sync: true }) // alias for "replace"
// 2) pushState — every change is a back-button step
createSearchParamsAdapter({ sync: "push" })
// 3) Bring your own — Next.js / React Router / debouncing
createSearchParamsAdapter({
sync: (search) => router.replace({ search }),
})
Mount-time normalisation
The writer fires once right after the builder is constructed in all three
modes, so the URL bar lands on the final state immediately — no mutation
needed for urlOmit, defaults, or stale
links to take effect. For sync: "push"
the very first call still uses replaceState
(so this mount fire doesn't add a phantom back-button entry). Custom
callbacks also fire on mount — guard with a closure flag if your router
would loop on a redirect.
Other apps' query params are safe
The writer preserves anything it doesn't recognise. So
?utm_source=newsletter or
?theme=dark stays untouched while your
filters get added, updated, or cleared.
Reacting to external changes #
The adapter read() runs once at
mount. After that, the builder owns the state. If something else changes
the underlying source — the user clicks Back/Forward, another tab mutates
localStorage, a websocket pushes a new
server-side filter — the builder won't notice on its own.
builder.rehydrate() re-runs the adapter's
read() and replaces the data layer
(filters / sorts / includes / fields / params / pagination) with whatever the
source now says. Config — aliases, delimiters,
pruneConflictingFilters,
useQuestionMark — is preserved.
const builder = useQueryBuilder({
adapter: createSearchParamsAdapter({ sync: true }),
})
// Re-pull state from the URL after the browser navigates.
useEffect(() => {
const onPop = () => builder.rehydrate()
window.addEventListener("popstate", onPop)
return () => window.removeEventListener("popstate", onPop)
}, [builder])
Why manual and not automatic? Different sources need different listeners
(popstate for history,
storage for cross-tab localStorage, a
websocket subscription for server state, …). Wiring the right listener belongs
in your code, where the choice is obvious. The library stays out of your way.
Other sources
Same pattern works for any custom adapter. A localStorage adapter pairs
with window.addEventListener("storage", …)
to react to other tabs. A server-backed adapter pairs with whatever
push mechanism you already use.
Aliases #
You probably name things one way in the UI (userName,
createdAt) and another in the API
(name, created_at).
Pass aliases to the builder and the adapter
translates in both directions automatically.
useQueryBuilder({
aliases: { userName: "name", createdAt: "created_at" },
adapter: createSearchParamsAdapter({ sync: true }),
})
// Your code uses frontend names:
builder.filter("userName", "Jane").sort("createdAt", "desc")
// URL bar shows backend names (same as .build()):
// /?filter[name]=Jane&sort=-created_at
// On refresh, ?filter[name]=Jane hydrates as { userName: "Jane" }.
The adapter reads the aliases from the builder config automatically, so you only declare them once.
Different names for URL and backend
The URL and the backend don't have to share the same naming. Maybe you want the
URL to speak the user's language (?filter[documento]=…)
while your API keeps technical names (?filter[code]=…).
Pass urlAliases to the adapter independently
of the builder's aliases:
useQueryBuilder({
aliases: { dni: "code", rol: "type" }, // state → backend
adapter: createSearchParamsAdapter({
sync: true,
urlAliases: { dni: "documento", rol: "type" }, // state → URL bar
}),
})
builder.filter("dni", "12345").filter("rol", "admin")
// URL bar: ?filter[documento]=12345&filter[type]=admin ← user sees this
// .build(): ?filter[code]=12345&filter[type]=admin ← API receives this
Three independent namespaces for the same logical attribute:
| Layer | Name | Controlled by |
|---|---|---|
| State (your code) | dni, rol | how you call .filter() |
| URL bar | documento, type | adapter.urlAliases |
| Backend | code, type | builder.aliases |
Omit urlAliases to make the URL share the
builder's names (the common case). Pass urlAliases: {}
to opt out of any URL translation — the URL then mirrors your state-space names
verbatim, even when builder.aliases is set.
Security note
With URL ≠ backend, an attacker who knows the backend name could craft
?filter[code]=… directly. Lock it down with
an allowlist on the adapter:
adapter: createSearchParamsAdapter({
sync: true,
urlAliases: { dni: "documento", rol: "type" },
allowed: { filters: ["documento", "type"] }, // backend-name attempts → dropped
})
Hide noisy state from the URL #
Sometimes the builder carries entries your backend needs but the user
shouldn't see in the URL bar — typical case: default
includes the API always requires
(organization,
permissions) that just clutter
shareable links. urlOmit is a
writer-only denylist per bucket:
useQueryBuilder({
includes: ["organization", "permissions"], // always sent to API
adapter: createSearchParamsAdapter({
sync: true,
urlOmit: { includes: ["organization", "permissions"] },
}),
})
builder.filter("status", "active")
// .build() → ?filter[status]=active&include=organization,permissions ← backend gets them
// URL bar → ?filter[status]=active ← user sees only this
Alias-aware on filters and
sorts — list a name in either
vocabulary (state or URL) and both forms are skipped.
Wildcard "*" drops every
entry in that bucket. Most common use: hide all
fields from the URL, since they're an
internal API optimisation the user rarely cares about. IDE autocomplete
surfaces "*" via a literal-union typing
trick.
adapter: createSearchParamsAdapter({
sync: true,
urlOmit: {
fields: ["*"], // hide ALL fields
includes: ["organization", "permissions"], // hide only these
},
})
Reader is not affected
urlOmit only skips on write.
If a crafted URL contains one of these names, the reader still
processes it. Add the same names to excludeKeys
if you also want to refuse them on hydration.
Renaming URL keys #
The default URL can get long fast:
?filter[status]=active&filter[role]=admin&sort=-created_at&include=author,tags&fields[user]=id,name
Remap the keys to shorten it:
createSearchParamsAdapter({
keys: { filter: "filt", sort: "srt", include: "inc", fields: "fld" },
sync: true,
})
// → ?filt[status]=active&filt[role]=admin&srt=-created_at&inc=author,tags&fld[user]=id,name
Why you might want this:
- Shorter, more shareable links — saves bytes per param.
- Cleaner URL bar — easier on the eye for users and screenshots.
- Brand / domain language — your app says "query", not "filter".
- Collision avoidance — the surrounding app already uses
?filter=…for something else.
Reader and writer use the same keys, so round-trips still work. This only
affects the URL bar — .build() (what you
pass to fetch) keeps using the canonical
names your backend expects.
Locking it down #
The URL is user input. Without limits, a crafted link like
?filter[is_admin]=true would flow straight
into your .build() call and out to your
backend. Two primitives, per bucket, cover the two opposite
needs.
allowed — strict allowlist ("only these")
Use it when you have a short, explicit list of what's legitimate.
createSearchParamsAdapter({
allowed: {
filters: ["status", "role", "created_at"],
sorts: ["created_at", "name"],
params: ["locale", "tenant"], // anything else is dropped
},
})
Defaults when you omit a bucket:
filters/sorts/includes/fields→ allow everything (so URL-driven filtering keeps working).params→ deny everything (the catch-all is deny-by-default; you must opt in by listing keys).
excludeKeys — targeted denylist ("everything except these")
Use it when you trust the bucket in general but want to block a handful of dangerous names — without enumerating everything legitimate.
createSearchParamsAdapter({
// No allowed.filters → any filter from the URL is accepted.
// I have 47 legitimate filter attributes and add more every month;
// maintaining a whitelist is brittle. But is_admin and password
// must NEVER come through the URL.
excludeKeys: {
filters: ["is_admin", "password"],
},
})
Which one should I use?
| Situation | Use |
|---|---|
| "Short, explicit list of what's allowed" | allowed |
| "Accept everything, except these few" | excludeKeys |
| "Whitelist plus a moving denylist on top" | both — defense in depth |
Alias-aware on filters and sorts
When you set aliases, the policy
matches if either the URL-side name (backend) OR the
state-side name (frontend) is in the list. Write your rules in whichever
vocabulary you think in — and listing one form on the deny side blocks
both, so an attacker can't bypass by switching vocabularies.
A few more details:
- Both apply to the reader and the writer symmetrically — the URL bar is always inside your policy.
excludeKeysalways wins overallowed(deny beats allow).- For
fields, match by short prop (password) or byentity.prop(user.password). Pick the precision you need. - Why per bucket?
passwordis dangerous as a filter but fine as afieldsselection (just picking which column the API returns). One flat list would force you into all-or-nothing.
Custom adapters #
QueryBuilderAdapter is just
{ read, write? }. Wrap anything:
// Persist to localStorage instead of the URL
const localStorageAdapter: QueryBuilderAdapter = {
read: () => JSON.parse(localStorage.getItem("filters") ?? "{}"),
write: (state) => localStorage.setItem("filters", JSON.stringify(state)),
}
useQueryBuilder({ adapter: localStorageAdapter })
Same pattern works for react-router search
params, hash routers, in-memory stores, IndexedDB — anything you can read
from and write to.
Low-level utilities #
If you only want the URL parser/serializer without the hook integration, both pieces are exported as pure functions.
import {
parseSearchParams,
serializeSearchParams,
} from "@cgarciagarcia/react-query-builder"
// URL → state
parseSearchParams("?filter[status]=active&sort=-name")
// → { filters: [...], sorts: [{ attribute: "name", direction: "desc" }] }
// state → URL
serializeSearchParams({
filters: [{ attribute: "status", value: ["active"] }],
})
// → "filter[status]=active"
Both accept the same keys /
urlAliases /
allowed /
excludeKeys options as the adapter, plus an
optional pre-compiled PolicyGate as the
third argument for the very perf-conscious.
Known limitations #
The URL protocol uses a few characters as control symbols. Filter values containing them silently corrupt on round-trip — the writer emits the value, but the reader splits it differently. Until we lift this limitation, treat these as off-limits inside filter values:
| Character | Why it breaks |
|---|---|
, | Multi-value separator — filter[tag]=a,b becomes ["a", "b"] on parse. |
<, >, <=, >=, <> (leading) | Operator syntax — filter[age]=>=18 becomes operator >= + value ["18"]. |
Other "special-looking" characters like %,
&, +
and spaces are fine — they travel URL-escaped and survive the round-trip
intact.
If you need any of these inside a value (e.g. tags with commas), keep that
piece of state outside the URL adapter, or escape it on your side before
passing it to .filter().
Try it live
The playground has dedicated examples for the URL adapter, two-way sync, and a localStorage adapter.
Open Playground