Connect your API
Turn the filter into the request your backend expects, with a serializer.
The URL stores the filter as status__eq=paid, but your backend probably expects something else. A serializer translates the filter into your backend's format.
Pick a serializer
| Your backend | Serializer | Status is Paid and Amount ≥ 10 becomes |
|---|---|---|
| Most REST APIs (the default) | jsonApiSerializer | filter[status][eq]=paid&filter[amount][gte]=10 |
| Django with django-filter | djangoSerializer | status=paid&amount__gte=10 |
| PostgREST, Supabase | postgrestSerializer | status=eq.paid&amount=gte.10 |
| Another param format (Strapi…) | createParamsSerializer | whatever you write per rule |
| Prisma, GraphQL, SQL, a JSON body | a function | anything |
Use it
Create the serializer once, outside your components, and pass it to the provider:
import { djangoSerializer } from "@querycn/filter-core"
const serializer = djangoSerializer()
<FilterProvider fields={orderFields} adapter={adapter} serializer={serializer}>With the table in server mode, that's all: useTableQuery puts the filter in the request, next to the search, sort and page.
Without the table, read the result with useAppliedFilter. toSearchParams turns it into URLSearchParams for fetch, axios or ky:
import { toSearchParams } from "@querycn/filter-core"
import { useAppliedFilter } from "@querycn/filter-react"
function OrdersTable() {
const { query } = useAppliedFilter()
// query: { status: "paid", amount__gte: "10" }
const url = `/api/orders?${toSearchParams(query)}`
// …
}Giving the serializer to the provider also tells the UI what your backend can handle. For example, django-filter has no "does not contain", so that operator isn't offered.
On the server
A serializer is a plain function, so it works on the server too:
const filter = parseFilters(await props.searchParams, { fields: orderFields })
const query = serializer(filter, { fields: orderFields })The built-in serializers
All the examples below come from the same filter:
?status__eq=active&amount__between=10,50&tags__in=urgent,vip&name__notContains=test&createdAt__isEmpty&paid__eq=trueJSON:API style (default)
Used when you don't pass a serializer. One key per field and operator:
filter[status][eq]=active
&filter[amount][between]=10&filter[amount][between]=50
&filter[tags][in]=urgent&filter[tags][in]=vip
&filter[name][notContains]=test
&filter[createdAt][isEmpty]=true
&filter[paid][eq]=true- Every operator is supported.
- Operators without a value, like
isEmpty, sendtrue. - OR filters add
filter[join]=or.
Change the format with options:
jsonApiSerializer({
prefix: "q",
arrayFormat: "comma",
operators: { notContains: "not_contains", isEmpty: undefined },
})
// q[amount][between]=10,50 q[tags][in]=urgent,vip q[name][not_contains]=test
// isEmpty is turned off, so the UI no longer offers itProp
Type
django-filter
For django-filter lookups:
import { djangoSerializer } from "@querycn/filter-core"
djangoSerializer()status=active&amount__range=10,50&tags__in=urgent,vip&createdAt__isnull=true&paid=trueThe name does not contain test rule is missing: django-filter has no lookup for it. The rule is skipped, and the UI shows a warning on it.
| Operator | Lookup | Example |
|---|---|---|
eq | none | status=active |
contains | icontains | name__icontains=an |
startsWith / endsWith | istartswith / iendswith | name__istartswith=an |
gt gte lt lte | same name | amount__gte=10 |
between | range | amount__range=10,50 |
in | in | tags__in=urgent,vip |
isEmpty / isNotEmpty | isnull | createdAt__isnull=true / false |
ne, notContains, notIn | not supported | skipped |
If your FilterSet defines more lookups, add them:
djangoSerializer({ lookups: { ne: "ne", contains: "contains" } })
// status__ne=paid customer__contains=anProp
Type
OR needs work on your side
Plain django-filter always combines filters with AND and ignores conjunction=or. Either handle it in your FilterSet, or set allowJoinToggle={false} on the builder so users can't pick OR.
Lists are sent comma-separated, as django-filter expects. A rule with a comma inside a list item is skipped.
PostgREST and Supabase
For PostgREST and Supabase's REST API:
import { postgrestSerializer } from "@querycn/filter-core"
postgrestSerializer()status=eq.active&amount=gte.10&amount=lte.50&tags=in.(urgent,vip)
&name=not.ilike.*test*&createdAt=is.null&paid=eq.trueor=(status.eq.active,and(amount.gte.10,amount.lte.50),tags.in.(urgent,vip),name.not.ilike.*test*,createdAt.is.null,paid.eq.true)| Operator | PostgREST |
|---|---|
eq ne gt gte lt lte | eq. neq. gt. gte. lt. lte. |
contains / notContains | ilike.*x* / not.ilike.*x* |
startsWith / endsWith | ilike.x* / ilike.*x |
between | gte.a and lte.b |
in / notIn | in.(a,b) / not.in.(a,b) |
isEmpty / isNotEmpty | is.null / not.is.null |
To change how an operator is sent, pass operators. Each entry returns the filters for one rule, or [] to skip it. Always pass user input through quote:
postgrestSerializer({
operators: {
contains: (rule, quote) => (rule.arity === "single" ? [`fts.${quote(String(rule.value))}`] : []),
},
})
// OR: or=(customer.fts."nguyen van",status.eq.paid)Good to know:
- Special characters are escaped for you: values are quoted inside
in.(…)andor=(…), and%and_in text match literally.*can't be escaped in PostgREST, so it stays a wildcard. - Field names can't contain
,.(), and can't beor,and,select,order,limitoroffset. To filter columns of embedded resources, write your own serializer.
Adjust rules with mapRule
Every built-in serializer takes a mapRule option. It sees each rule before it's sent, and returns the rule, a changed copy, or undefined to skip it. The URL doesn't change, only the request:
jsonApiSerializer({
mapRule: (rule) => {
// The URL says "customer", the API wants "customer.name"
if (rule.field === "customer") return { ...rule, field: "customer.name" }
// "On or before Sep 30" should include the whole day
if (rule.field === "createdAt" && rule.operator === "lte" && rule.arity === "single") {
return { ...rule, value: `${rule.value}T23:59:59` }
}
// Never send fields marked in meta
if (rule.definition.meta?.clientOnly) return undefined
return rule
},
})
// filter[customer.name][contains]=an&filter[createdAt][lte]=2026-09-30T23:59:59- Don't change the operator. To rename operators, use the serializer's
operatorsorlookupsoption. - Keep it pure. It can run more than once per apply, so don't read
Date.now()or other changing state. - Skipped rules get a warning in the UI.
Write your own
Another param format
If your backend reads query params in another format, use createParamsSerializer. You only write how one rule becomes params. OR handling, mapRule and the UI warnings come for free. This one sends Strapi filters:
import { createParamsSerializer, type OperatorId } from "@querycn/filter-core"
const STRAPI: Partial<Record<OperatorId, string>> = {
eq: "$eq", ne: "$ne", contains: "$containsi", gt: "$gt", gte: "$gte", lt: "$lt", lte: "$lte",
between: "$between", in: "$in", notIn: "$notIn", isEmpty: "$null", isNotEmpty: "$notNull",
}
export const strapiSerializer = createParamsSerializer({
encodeRule: ({ field, operator, value }, { index, join }) => {
const op = STRAPI[operator]
if (!op) return undefined // skipped, with a warning in the UI
const key = join === "or" ? `filters[$or][${index}][${field}][${op}]` : `filters[${field}][${op}]`
if (value === null) return [[key, "true"]]
if (Array.isArray(value)) return value.map((item, i) => [`${key}[${i}]`, String(item)])
return [[key, String(value)]]
},
supports: (operator) => operator in STRAPI,
})
// OR: filters[$or][0][status][$eq]=paid&filters[$or][1][amount][$between][0]=10&filters[$or][1][amount][$between][1]=50Prop
Type
Anything else: Prisma, GraphQL…
A serializer is just a function (filter, context) => anything. Use getAppliedRules to get the rules with their field definition, then build what you need. This one returns a Prisma where:
import { getAppliedRules, type AppliedRule, type QuerySerializer } from "@querycn/filter-core"
import type { Prisma } from "@prisma/client"
function toCondition(rule: AppliedRule): Prisma.OrderWhereInput | undefined {
const { field } = rule
switch (rule.operator) {
case "eq":
return { [field]: rule.value }
case "contains":
return { [field]: { contains: rule.value, mode: "insensitive" } }
case "between":
return rule.arity === "range" ? { [field]: { gte: rule.value[0], lte: rule.value[1] } } : undefined
case "in":
return rule.arity === "multi" ? { [field]: { in: rule.value } } : undefined
case "isEmpty":
return { [field]: null }
// …
}
}
export const prismaWhere: QuerySerializer<Prisma.OrderWhereInput> = (state, context) => {
const { join, rules } = getAppliedRules(state, context)
const conditions = rules.map(toCondition).filter((c) => c !== undefined)
return join === "or" ? { OR: conditions } : { AND: conditions }
}const filter = parseFilters(await props.searchParams, { fields: orderFields })
const orders = await prisma.order.findMany({ where: prismaWhere(filter, { fields: orderFields }) })Checking rule.arity tells TypeScript the value's shape: null, one value, a [from, to] pair or a list. rule.definition is the field, including its meta.
To use it on the client, pass the output type to the hook: useAppliedFilter<Prisma.OrderWhereInput>().
Values are user input
Rules come from the URL. Field names and operators are checked against your fields, but values are only checked for shape: a number field can carry text like "12,5", and select values aren't checked against the options. Validate them like any other request input, and never put them into SQL by hand.
Warnings in the UI
The built-in serializers (and those from createParamsSerializer) can tell the UI about problems. You don't need to call these yourself, but it helps to know what the warning icons mean:
const serializer = djangoSerializer()
serializer.supports("ne") // false: "is not" isn't offered in the operator picker
serializer.inspect(filter, { fields })
// { skipped: ["r3"], conflicts: [] }- Unsupported (
skipped): the rule can't be sent, so your backend never sees it. For example, an operator that's turned off, a rule dropped bymapRule, or a comma in a list item. - Conflict (
conflicts): the rule sends the same key as an earlier rule, like two Status is … rules with the JSON:API serializer. Most backends only read one of them:
jsonApiSerializer()({ join: "and", rules: [statusIsPaid, statusIsPending] }, context)
// { "filter[status][eq]": ["paid", "pending"] } → the second rule conflictsSerializers you write as a plain function don't have these methods. Then every operator is offered, and nothing is flagged.