Research into existing patterns, libraries, and articles related to building a declarative, bidirectional filter system that maps between URL search params and structured query objects (e.g., GraphQL filters) in React/TypeScript.
A filter architecture with three co-located concerns per page:
URL Search Params <--> Filter Context (urlValues) <--> GraphQL Filter Object
Key characteristics:
- Fluent builder API for declaring filters:
.label().options().field().resolve().build() - Bidirectional mapping per filter via
.field()(UI value → query object) and.resolve()(query object → UI value) - URL as source of truth using nuqs for typed URL state management
- React Context bridge exposing
toGraphQLFilter()andfromGraphQLFilter() - Filter reuse/extension pattern where base configs are extended per-page with UI overrides
This pattern is novel. No existing library implements the full composition. The closest references cover individual layers but none combine all three: fluent builder DSL + bidirectional URL↔query mapping + React Context bridge.
- URL: https://www.harness.io/blog/powering-harness-executions-page-inside-our-flexible-filters-component
- Why it matters: The closest real-world architectural match. Implements:
useFiltersContext()backed by React Context- Centralized
FiltersMapstate object with typed values alongside serialized query strings - Full bidirectional sync: user interactions serialize to URL, URL changes parse back to typed state
- Inversion of Control architecture — framework handles when/how state updates, parents define what happens
- A parser interface for converting between typed values and URL-safe strings
- Gap: No fluent builder API. No explicit per-filter write/read functions — uses a more imperative approach.
- GitHub: https://github.com/openstatusHQ/data-table-filters
- Live demo: https://data-table.openstatus.dev/
- Guide: https://data-table.openstatus.dev/guide
- Why it matters: The most complete example of nuqs powering a declarative filter system:
createTableSchema()builder for declaring filter columns with types- Generators:
generateFilterFields(),generateColumns(),generateFilterSchema() - BYOS (Bring Your Own Store) adapter pattern — nuqs is one of four pluggable backends
- Schema-driven: define filters once, generate everything
- Gap: No GraphQL mapping layer. No per-filter
.field()/.resolve()bidirectional functions.
- Gist: https://gist.github.com/donaldpipowitch/95350eb1db4ac6fbecaff0b3790c712b
- Author: @donaldpipowitch (Donald Pipowitch)
- Why it matters: Closest match to a fluent, declarative URL filter config:
- Builder API:
configure().add(number('pageSize').default(10)).add(boolean('active').filter({ label: 'Active' })) - Strong typing via recursive generics
- Returns
filterValues,filterLabels,hasActiveFilters,resetFilters,setFilters - Custom
.serialize()/.deserialize()per param - Smart side effects (filter change auto-resets pagination)
- Builder API:
- Gap: Solves URL↔state cleanly but has no GraphQL mapping. Would need
.field()/.resolve()extensions for the second hop.
- Docs: https://refine.dev/docs/api-reference/core/hooks/useTable/
- Feature request: refinedev/refine#6620
- Why it matters: Refine has the closest pipeline to a full bidirectional system:
CrudFilter[]model:{ field, operator, value }objectssyncWithLocation: trueencodes filters into URL params- Data providers (e.g.,
@refinedev/hasura) convertCrudFilter[]→ GraphQLwhereclause
- Key insight: The community has explicitly requested the missing read/resolve direction (converting filters back to form values) as Issue #6620. This validates that the
.resolve()pattern fills a real, recognized gap in the ecosystem.
- Docs: https://nuqs.dev
- GitHub: https://github.com/47ng/nuqs (~10K stars, actively maintained)
- Role: The URL state layer. Key capabilities for filter systems:
useQueryStates()for atomic multi-key updates- Built-in parsers:
parseAsArrayOf,parseAsStringLiteral,parseAsIsoDate,parseAsJson - Custom parsers via
createParser({ parse, serialize, eq? }) urlKeysmapping to decouple code names from URL param names- Programmatic
setFilters()from external data (server responses, AI suggestions) createSearchParamsCache()for server-side access with shared parser definitions
- Docs: https://marmelab.com/react-admin/StackedFilters.html
- Relevance: Declarative filter config with helper functions (
textFilter(),numberFilter(),dateFilter()). URL persistence via JSON-encodedfilterparam. Convention-basedfield_operatornaming for the write direction. No per-filter read functions.
- URL: https://dev.to/a-dev/hook-to-manage-url-search-params-like-a-bossreact-routerusezodvalidatemessless-1d4
- Relevance:
URLSearchParamsProvidercontext + Zod schema validation + bidirectional read/write hooks. Author recommends nuqs or TanStack Router for production.
- URL: https://shaky.sh/fluent-interfaces-in-typescript/
- Relevance: Type-safe chaining with generics. Directly applicable to building a
.label().options().field().build()API. Shows how to progressively refine types through the chain.
- URL: https://aurorascharff.no/posts/managing-advanced-search-param-filtering-next-app-router/
- Relevance: nuqs + server-side filtering with a
FilterProvidercontext pattern.
- URL: https://blog.logrocket.com/advanced-react-state-management-using-url-parameters/
- Relevance: Bidirectional URL↔state with
useSearchParams, debouncing, API query integration. No GraphQL or Context pattern.
| Reference | Reason |
|---|---|
| react-zod-url-state (GitHub) | Exists but 8 stars, dormant since Jan 2025. Good DX reference, not production-worthy. |
| armand1m/react-query-filter (GitHub) | WIP, ~20 stars. In-memory filter UI state only — no URL sync, no bidirectional mapping. |
| Sunny Sun — "Apply Builder Pattern To Generate Query Filter In TypeScript" (Medium) | Classic GoF Builder for OData-style string concatenation. Unidirectional, no URL, no React integration. |
| TanStack Table + tanstack-table-search-params (GitHub) | Bidirectional URL sync for table state, but no declarative filter config or query mapping layer. |
| react-querybuilder (Docs) | Visual query builder with SQL/MongoDB export. Strong on structured query output, no URL sync. |
| React-SearchKit (Invenio) (GitHub) | Centralized query state with URL handler. Designed for Invenio/OpenSearch, not general-purpose. |
| Library/Pattern | Fluent Builder Config | Bidirectional URL Sync | Maps to Query Object | Per-Filter Read/Write | React Context |
|---|---|---|---|---|---|
| Your architecture | Yes | Yes (nuqs) | Yes (GraphQL) | Yes (.field() / .resolve()) |
Yes |
| Harness Filters | No (imperative) | Yes | Partial | No | Yes |
| openstatusHQ/data-table-filters | Yes (schema) | Yes (nuqs) | No | No | No |
| Hook Builder gist | Yes (fluent) | Yes | No | No | No |
| Refine useTable | No (array) | Yes | Yes (data provider) | No (requested in #6620) | Partial |
| react-admin StackedFilters | Yes (helpers) | Yes (JSON) | Via data provider | No (convention) | No |
| nuqs | No (primitives) | Yes | No | No | No |
| TanStack Router | No (infra) | Yes | No | No | No |
The filter architecture described here is a novel composition of three well-understood patterns:
- Fluent builder DSL for filter configuration (prior art: Hook Builder gist, react-admin helpers)
- Bidirectional URL ↔ structured query mapping with explicit write/read functions per filter (prior art: Refine's pipeline, but
.resolve()is their missing piece — Issue #6620) - React Context bridge with
toGraphQLFilter()/fromGraphQLFilter()(prior art: Harness engineering article)
Each layer exists independently in the ecosystem. The contribution is combining them into a cohesive system where filters are declared once and automatically handle URL serialization, UI rendering, and GraphQL query generation — in both directions.
Research conducted March 2026. References verified via direct URL access and source code inspection.