Use without the table
Use the filter on its own, above a list of cards, a report, or only on the server.
The filter is built for the tablecn table, but doesn't depend on it. Use it on its own when you already have a list, a grid of cards or a report, or when you only need to read ?status__eq=paid in an API route.
Here the filter sits above a plain HTML table, not the tablecn table. Apply a filter: the rows are filtered in the browser, the filter is written to the URL, and under the rows you can see the request a JSON:API-style backend would receive.
Install
Add only the filter UI (see Installation for the options):
pnpm dlx shadcn@latest add @tablecn/filter-builderThen declare your fields, and render the builder in a provider for your router:
pnpm add @querycn/filter-next"use client"
import { NextFilterProvider } from "@querycn/filter-next"
import { FilterBuilder } from "@/components/filter/filter-builder"
import { FilterChips } from "@/components/filter/filter-chips"
import { orderFields } from "@/lib/order-fields"
export function OrdersFilter({ children }: { children?: React.ReactNode }) {
return (
<NextFilterProvider fields={orderFields}>
<div className="flex flex-wrap items-center gap-2">
<FilterBuilder />
<FilterChips />
</div>
{children}
</NextFilterProvider>
)
}Without an adapter, the filter lives in memory and the URL is left alone.
Load the filtered data
Pick the case that fits:
From your API, in the browser
Anywhere inside the provider, useAppliedFilter() gives you the request params for the applied filter:
import { toSearchParams } from "@querycn/filter-core"
import { useAppliedFilter } from "@querycn/filter-react"
function OrderList() {
const { query, queryKey } = useAppliedFilter()
// query: { "filter[status][eq]": "paid" }
const { data } = useQuery({
queryKey: ["orders", queryKey],
queryFn: () => fetch(`/api/orders?${toSearchParams(query)}`).then((r) => r.json()),
})
// …
}queryKey is a short string that changes when the filter changes, handy for cache keys. For another format, pass a serializer to the provider.
On the server, with Next.js
By default, applying a filter updates the URL and Next.js renders the page again on the server. Read the filter from searchParams:
import { jsonApiSerializer, toSearchParams } from "@querycn/filter-core"
import { parseFilters } from "@querycn/filter-next/server"
import { orderFields } from "@/lib/order-fields"
import { OrdersFilter } from "./orders-filter"
const serializer = jsonApiSerializer()
export default async function Page(props: PageProps<"/orders">) {
const filter = parseFilters(await props.searchParams, { fields: orderFields })
const query = serializer(filter, { fields: orderFields })
const orders = await fetch(`${process.env.API_URL}/orders?${toSearchParams(query)}`).then((r) => r.json())
return (
<OrdersFilter>
<OrderList orders={orders} />
</OrdersFilter>
)
}In a route handler, pass the request's params:
import type { NextRequest } from "next/server"
import { parseFilters } from "@querycn/filter-next/server"
export async function GET(request: NextRequest) {
const filter = parseFilters(request.nextUrl.searchParams, { fields: orderFields })
// …
}parseFilters checks the URL like the client does: a broken or outdated link gives fewer rules, never an error.
Prop
Type
In the browser, without an API
When every row is already loaded, filter them with applyFilter. It applies the same rules, the same way the table does in client mode:
"use client"
import { applyFilter } from "@querycn/filter-core"
import { useAppliedFilter } from "@querycn/filter-react"
function OrderList({ orders }: { orders: Order[] }) {
const { state, context } = useAppliedFilter()
const rows = React.useMemo(() => applyFilter(orders, state, context), [orders, state, context])
// render rows…
}With Next.js, add shallow to the provider (<NextFilterProvider fields={orderFields} shallow>): the URL is updated without asking the server for a new page.
Not for paginated data
Filtering one page of a paginated API in the browser gives wrong results: matching rows on other pages are never seen. Send the filter to your backend instead.
applyFilter(rows, state, context) takes these options in context. useAppliedFilter().context already has fields and registry:
Prop
Type
createRowMatcher(state, context) returns a (row) => boolean instead of a filtered array, for when you need a predicate.
Anywhere else
@querycn/filter-core has no dependencies, so it runs in any backend, script or test. See URL state to read and write the filter's URL params by hand.
Next.js notes
- Keep fields in a shared module, without
"use client", and import it on both sides. You can't passfieldsas a prop from a server component to a client component if a field hasloadOptionsorresolveLabels: functions can't cross that boundary. The same goes for serializers. shallowor not? Leave it off when a server component readssearchParams, so each filter change fetches the page again. Turn it on when rows are filtered in the browser.- Static routes need
<Suspense>. The provider reads the URL withuseSearchParams, so on a prerendered routenext buildfails unless the provider is inside a<Suspense>boundary. Pages that awaitsearchParamsare dynamic already. - Next.js 14:
searchParamsis a plain object, not a Promise:parseFilters(props.searchParams, { fields }). The examples use Next.js 15.5+ types likePageProps<"/orders">. NextFilterProvidertakes everyFilterProviderprop exceptadapter, plusshallow. To use the adapter with your own provider, calluseNextAdapter({ shallow }). Both keep the path, hash and other params, don't scroll, and handlebasePath.