Search documentation

Search for a page or heading...

tablecn
0

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 backendSerializerStatus is Paid and Amount ≥ 10 becomes
Most REST APIs (the default)jsonApiSerializerfilter[status][eq]=paid&filter[amount][gte]=10
Django with django-filterdjangoSerializerstatus=paid&amount__gte=10
PostgREST, SupabasepostgrestSerializerstatus=eq.paid&amount=gte.10
Another param format (Strapi…)createParamsSerializerwhatever you write per rule
Prisma, GraphQL, SQL, a JSON bodya functionanything

Use it

Create the serializer once, outside your components, and pass it to the provider:

app/orders/orders-filter.tsx
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:

components/orders-table.tsx
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:

app/orders/page.tsx
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:

URL
?status__eq=active&amount__between=10,50&tags__in=urgent,vip&name__notContains=test&createdAt__isEmpty&paid__eq=true

JSON:API style (default)

Used when you don't pass a serializer. One key per field and operator:

Request
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, send true.
  • OR filters add filter[join]=or.

Change the format with options:

Custom 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 it

Prop

Type

django-filter

For django-filter lookups:

Usage
import { djangoSerializer } from "@querycn/filter-core"

djangoSerializer()
Request
status=active&amount__range=10,50&tags__in=urgent,vip&createdAt__isnull=true&paid=true

The 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.

OperatorLookupExample
eqnonestatus=active
containsicontainsname__icontains=an
startsWith / endsWithistartswith / iendswithname__istartswith=an
gt gte lt ltesame nameamount__gte=10
betweenrangeamount__range=10,50
inintags__in=urgent,vip
isEmpty / isNotEmptyisnullcreatedAt__isnull=true / false
ne, notContains, notInnot supportedskipped

If your FilterSet defines more lookups, add them:

Custom lookups
djangoSerializer({ lookups: { ne: "ne", contains: "contains" } })
// status__ne=paid  customer__contains=an

Prop

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:

Usage
import { postgrestSerializer } from "@querycn/filter-core"

postgrestSerializer()
Request (AND)
status=eq.active&amount=gte.10&amount=lte.50&tags=in.(urgent,vip)
&name=not.ilike.*test*&createdAt=is.null&paid=eq.true
Request (OR)
or=(status.eq.active,and(amount.gte.10,amount.lte.50),tags.in.(urgent,vip),name.not.ilike.*test*,createdAt.is.null,paid.eq.true)
OperatorPostgREST
eq ne gt gte lt lteeq. neq. gt. gte. lt. lte.
contains / notContainsilike.*x* / not.ilike.*x*
startsWith / endsWithilike.x* / ilike.*x
betweengte.a and lte.b
in / notInin.(a,b) / not.in.(a,b)
isEmpty / isNotEmptyis.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:

Full-text search instead of ilike
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.(…) and or=(…), 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 be or, and, select, order, limit or offset. 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:

mapRule
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 operators or lookups option.
  • 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:

lib/strapi-serializer.ts
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]=50

Prop

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:

lib/prisma-where.ts
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 }
}
app/orders/page.tsx
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:

What the UI asks
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 by mapRule, 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:
Conflict
jsonApiSerializer()({ join: "and", rules: [statusIsPaid, statusIsPending] }, context)
// { "filter[status][eq]": ["paid", "pending"] }  → the second rule conflicts

Serializers you write as a plain function don't have these methods. Then every operator is offered, and nothing is flagged.