DEV Community

torshid
torshid

Posted on

Fullstack Dynamic Filtering: React Query Builder to Spring Boot in One Pipeline

Here's something that took me way too long to realize: filtering isn't a frontend problem or a backend problem. It's a pipeline problem. Your React app builds a query object. Your Spring Boot app receives a query string. If the two disagree on what >= means or how between works or whether name ~ 'A%' is case-sensitive, you're shipping a bug that neither side will catch because neither side is wrong. They're just speaking different languages.

FilterKit and Spring Filter speak the same language. Exactly the same syntax, same AST, same operator precedence. What gets built on the frontend is byte-for-byte what the backend expects. This article walks through the full pipeline: React query builder → filter expression string → HTTP request → Spring Boot controller → JPA Specification → SQL query.

I'll walk through two frontend approaches since different teams prefer different UIs: react-querybuilder for a Jira-style filter panel, and TanStack Table for column header filters. Both end up producing the exact same query string on the wire. The backend doesn't know or care which one you used.

I'll use a car dealership inventory app as the example because it has everything: range filters (year, km), multi-select (brand), fuzzy text search (model name), and nested relations (brand → manufacturer → country). Real-world messy.

The app we're building

A single-page React app with a filter panel on the left and a results table on the right. The filter panel has:

  • Year: range slider (2010 to 2025)
  • Brand: multi-select checkboxes
  • Model: text search with autocomplete
  • Max km: number input
  • Color: enum dropdown
  • Accident history: toggle (has accidents / no accidents)

When any filter changes, the table updates. Filtering happens server-side (manual filtering, not client-side). Pagination, sorting, and field selection are included but I won't dwell on them; they're in the Spring Filter page-sort module and just work.

Step 1: The filter query string format

Before we write any code, we agree on the wire format. Spring Filter and FilterKit share this:

year between 2018 and 2025 and brand.name in ['audi','bmw'] and km < 50000
Enter fullscreen mode Exit fullscreen mode

Operators: : for equals, ! for not, > < >: <: for comparisons, ~ for LIKE, ~~ for case-insensitive LIKE, in / not in for collections, is null / is empty for null/empty checks, and / or / xor / not for logic. Parentheses for grouping. All case-insensitive.

This gets URL-encoded and sent as ?filter=year%20between.... The backend parses it, turns it into a JPA Specification, runs the query.

Step 2: The Spring Boot backend

Standard Spring Boot setup. Dependencies:

<dependency>
    <groupId>com.turkraft.springfilter</groupId>
    <artifactId>jpa</artifactId>
    <version>4.0.4</version>
</dependency>
<dependency>
    <groupId>com.turkraft.springfilter</groupId>
    <artifactId>page-sort</artifactId>
    <version>4.0.4</version>
</dependency>
<dependency>
    <groupId>com.turkraft.springfilter</groupId>
    <artifactId>openapi</artifactId>
    <version>4.0.4</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Car entity (abbreviated, you know what an entity looks like):

@Entity
public class Car {
    @Id @GeneratedValue
    private Long id;
    private int year;
    private int km;
    private String model;
    @Enumerated(EnumType.STRING)
    private Color color;
    @ManyToOne
    private Brand brand;
    @OneToMany(mappedBy = "car")
    private List<Accident> accidents;
}
Enter fullscreen mode Exit fullscreen mode

Brand entity:

@Entity
public class Brand {
    @Id @GeneratedValue
    private Long id;
    private String name;
    @ManyToOne
    private Manufacturer manufacturer;
}
Enter fullscreen mode Exit fullscreen mode

Repository:

public interface CarRepository extends JpaRepository<Car, Long>,
    JpaSpecificationExecutor<Car> {}
Enter fullscreen mode Exit fullscreen mode

Controller:

@Fields
@GetMapping("/cars")
public Page<Car> search(
        @Filter Specification<Car> spec,
        @Pagination Pageable pageable) {
    return carRepository.findAll(spec, pageable);
}
Enter fullscreen mode Exit fullscreen mode

That's the whole controller. Three lines. The @Filter annotation tells Spring Filter to parse the ?filter= query parameter into a JPA Specification. The @Pagination annotation handles ?page=0&size=20&sort=-year. The @Fields annotation handles ?fields=id,model,year,brand.name. All without writing any parameter parsing code.

What happens under the hood when ?filter=year between 2018 and 2025 and km < 50000 arrives:

  1. Spring MVC extracts the filter parameter
  2. FilterNodeArgumentResolver picks it up (because of @Filter)
  3. FilterParser tokenizes and parses the string into an AST
  4. FilterSpecificationConverter walks the AST and builds a JPA Criteria Specification<Car>
  5. Spring Data JPA's findAll(Specification, Pageable) executes it
  6. Hibernate generates SQL like:
   SELECT ... FROM car c
   LEFT JOIN brand b ON c.brand_id = b.id
   WHERE c.year >= 2018 AND c.year <= 2025 AND c.km < 50000
   LIMIT 20 OFFSET 0
Enter fullscreen mode Exit fullscreen mode

Zero custom query methods. Zero @Query annotations. The controller doesn't know what fields exist on Car. Add a new field to the entity and it's automatically filterable.

Step 3: The React frontend: react-querybuilder

For the filter panel, I'm using react-querybuilder. It gives you a drag-and-drop query builder UI with rule groups, combinators, and operator selectors. Users who've used Jira's advanced search or Salesforce reports know the pattern.

Install:

npm install react-querybuilder @turkraft/filterkit-querybuilder @turkraft/filterkit @tanstack/react-query
Enter fullscreen mode Exit fullscreen mode

Configure the query builder with the fields and operators our API supports:

import { useState } from 'react';
import { QueryBuilder } from 'react-querybuilder';
import { toFilterExpression } from '@turkraft/filterkit-querybuilder';
import { useQuery } from '@tanstack/react-query';

const fields = [
  { name: 'year',       label: 'Year',        inputType: 'number' },
  { name: 'km',          label: 'Kilometers',   inputType: 'number' },
  { name: 'model',       label: 'Model',        inputType: 'text' },
  { name: 'brand.name',  label: 'Brand',        inputType: 'text' },
  { name: 'color',       label: 'Color',        inputType: 'text' },
  { name: 'accidents',   label: 'Accidents',    inputType: 'text' },
];

const operators = [
  { name: '=',       label: 'is' },
  { name: '!=',      label: 'is not' },
  { name: '<',       label: 'less than' },
  { name: '>',       label: 'greater than' },
  { name: '<=',      label: 'less or equal' },
  { name: '>=',      label: 'greater or equal' },
  { name: 'between', label: 'between' },
  { name: 'in',      label: 'in' },
  { name: 'contains', label: 'contains' },
  { name: 'beginsWith', label: 'starts with' },
  { name: 'null',    label: 'is null' },
  { name: 'notNull', label: 'is not null' },
];

const initialQuery = {
  combinator: 'and',
  rules: [],
};
Enter fullscreen mode Exit fullscreen mode

The component:

function CarFilterPanel() {
  const [query, setQuery] = useState(initialQuery);

  // Convert react-querybuilder state → SpringFilter expression string
  const filterExpression = toFilterExpression(query);

  // Fetch cars whenever the filter changes
  const { data, isLoading } = useQuery({
    queryKey: ['cars', filterExpression],
    queryFn: () =>
      fetch(`/api/cars?filter=${encodeURIComponent(filterExpression)}`)
        .then(r => r.json()),
  });

  return (
    <div>
      <QueryBuilder
        fields={fields}
        query={query}
        onQueryChange={setQuery}
        controlClassnames={{ queryBuilder: 'queryBuilder-branches' }}
      />
      <CarTable cars={data?.content ?? []} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

That's it. The user builds a query in the UI. toFilterExpression() converts it to a SpringFilter string. The string goes in a URL parameter. The backend parses it natively. No translation layer, no mapping, no mismatches.

What does toFilterExpression() actually produce? If the user adds rules:

  • year >= 2018
  • year <= 2025
  • km < 50000

The output is: year >: '2018' and year <: '2025' and km < '50000'

If they add a "contains" rule on model with value "audi":
model ~ '%audi%'

The % is the SQL LIKE wildcard. Spring Filter's JPA transformer maps ~ to the JPA like() function and % works identically in both worlds. No case conversion, no parameter splitting, no "wait does contains mean substring match or word match?" ambiguity.

Step 4: TanStack Table with column header filters

If your app already uses TanStack Table for data display, you can skip the separate query builder panel and let users filter directly from column headers. FilterKit ships a TanStack adapter that translates column filter state into the same wire format.

Install:

npm install @tanstack/react-table @turkraft/filterkit-tanstack @turkraft/filterkit @tanstack/react-query
Enter fullscreen mode Exit fullscreen mode

Configure the table with manualFiltering: true. This tells TanStack your backend handles filtering, not the browser:

import { useReactTable, getCoreRowModel, getFilteredRowModel } from '@tanstack/react-table';
import type { ColumnFilter } from '@tanstack/react-table';
import { toFilterString } from '@turkraft/filterkit-tanstack';
import { useQuery } from '@tanstack/react-query';

function CarTable() {
  const [columnFilters, setColumnFilters] = useState<ColumnFilter[]>([]);

  const filterQuery = toFilterString(columnFilters);

  const { data } = useQuery({
    queryKey: ['cars', filterQuery],
    queryFn: () =>
      fetch(`/api/cars?filter=${encodeURIComponent(filterQuery)}`)
        .then(r => r.json()),
  });

  const table = useReactTable({
    data: data?.content ?? [],
    columns,
    state: { columnFilters },
    onColumnFiltersChange: setColumnFilters,
    manualFiltering: true,    // server-side, not client-side
    getCoreRowModel: getCoreRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
  });

  return <TableComponent table={table} />;
}
Enter fullscreen mode Exit fullscreen mode

toFilterString() takes TanStack's ColumnFilter[] array ([{ id: 'year', value: [2018, 2025] }, { id: 'km', value: [0, 50000] }]) and produces year between '2018' and '2025' and km between '0' and '50000'.

Same backend. Different frontend. Same wire format. The TanStack adapter handles the mapping for you. A range filter [2018, 2025] becomes year between '2018' and '2025', a string value 'audi' becomes brand.name : 'audi', an array of strings becomes an in clause. Same filter expression, same URL parameter, same backend response.

If you're wondering which frontend to pick: react-querybuilder gives you more flexibility (nested AND/OR groups, custom operators), TanStack Table is more streamlined (column headers, no separate panel). Both hit the same endpoint. You can even mix them: querybuilder on the admin dashboard, TanStack on the public listing page. The backend doesn't care.

Step 5: What if you need security? Multi-tenancy?

The pipeline doesn't break when you bolt on ParseContext. In fact, that's where it shines.

Let's say each dealership only sees their own cars. The filter panel still sends ?filter=year > 2020. The controller still receives it. But before it hits the repository, a ParseContext injects the tenant clause:

@Service
public class TenantCarService {

    @Autowired FilterParser parser;
    @Autowired FilterSpecificationConverter converter;
    @Autowired FilterBuilder fb;

    public Specification<Car> parse(String filter, Long dealershipId) {
        ParseContext ctx = new ParseContextImpl(null, userNode -> {
            FilterNode tenantFilter = fb.field("dealership.id")
                .equal(fb.input(dealershipId))
                .get();
            return fb.and(tenantFilter, userNode).get();
        });

        FilterNode node = parser.parse(filter, ctx);
        return converter.convert(node);
    }
}
Enter fullscreen mode Exit fullscreen mode

Your controller changes from the @Filter Specification shortcut to a manual call:

@GetMapping("/cars")
public Page<Car> search(@Filter String rawFilter,
                         @AuthenticationPrincipal User user,
                         @Pagination Pageable pageable) {
    Specification<Car> spec = tenantService.parse(rawFilter, user.getDealershipId());
    return carRepository.findAll(spec, pageable);
}
Enter fullscreen mode Exit fullscreen mode

The frontend never sends dealershipId. Doesn't even know it exists. The backend injects it. The SQL becomes:

WHERE (c.dealership_id = 42) AND (c.year >= 2020)
Enter fullscreen mode Exit fullscreen mode

You trade one line of magic (@Filter Specification<Car> spec) for one line of explicit control (tenantService.parse(rawFilter, ...)). Well worth it when the alternative is sprinkling tenant_id = ? through every repository method. Same pattern works for soft deletes, row-level security, audit logging. The frontend sends what the user typed. ParseContext adds what the system needs.

Step 6: MongoDB instead of JPA? Same frontend, different converter

If you switch from JPA to MongoDB, or run both simultaneously, the frontend code doesn't change. The filter expression is database-agnostic.

Backend with MongoDB:

@GetMapping("/cars")
public List<Car> search(@Filter(entityClass = Car.class) Query query, Pageable pageable) {
    return mongoTemplate.find(query.with(pageable), Car.class);
}
Enter fullscreen mode Exit fullscreen mode

The query.with(pageable) call applies skip and limit, so the result is already paginated even though we return a plain List.

Same ?filter=year > 2020, different converter: FilterQueryConverter instead of FilterSpecificationConverter. The AST is the same. The serialization is the same. Only the target output changes.

Database-agnostic filtering means you can start with JPA + H2 for development, move to PostgreSQL for staging, add MongoDB for a specific collection, and never touch the frontend. This is the kind of thing that saves you from rewriting filter panels when the CTO decides to "just try MongoDB for the search index."

Step 7: Bonus: automatic Swagger docs

Remember the openapi dependency we added? Launch the app, navigate to /swagger-ui.html, and the filter parameter on /cars is documented automatically:

  • Type: string (filter expression)
  • All filterable fields listed with types (year: integer, brand.name: string, color: enum)
  • Example queries generated from entity metadata
  • Operator reference table
  • Pagination and sort parameters documented

Your frontend team can read the Swagger docs and know exactly what fields are filterable without reading entity source code. If you add a field to the entity, it appears in Swagger on the next restart. No manual annotation updates.

Step 8: The complete architecture diagram

┌─────────────────────────────────────────┐
│  BROWSER                                │
│                                         │
│  react-querybuilder  or  TanStack       │
│       ↓ toFilterExpression /           │
│         toFilterString                 │
│  "year > 2020 and km < 50000"          │
│       ↓ encodeURIComponent()           │
│  GET /api/cars?filter=year%20...       │
└─────────────────────────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────┐
│  SPRING BOOT                            │
│                                         │
│  @Filter Specification<Car> spec        │
│       ↓ FilterParser.parse()           │
│  AST: AND(GT(year,2020), LT(km,50000)) │
│       ↓ ParseContext (optional)        │
│  AST: AND(tenant=42, AND(original...))  │
│       ↓ FilterSpecificationConverter   │
│  JPA Specification<Car>                │
│       ↓ repository.findAll(spec)       │
│  SQL: WHERE c.dealership_id = 42       │
│       AND c.year > 2020                │
│       AND c.km < 50000                  │
└─────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Wait, but this sends filter queries as URL params. Isn't that a security problem?

It's the same security model as ?page=2&size=20&sort=-year. The filter expression is parsed, validated, and turned into parameterized JPA Criteria queries. No SQL injection. No string concatenation. The parser rejects invalid syntax with a 400. The converter builds the query using CriteriaBuilder methods, not string templates.

If you need field-level access control on top of that, ParseContext handles it the same way as the tenant example above. Add a field mapper that throws if the field isn't on a per-role whitelist. The filter never touches the database if a restricted field is referenced.

Real talk: when does this NOT work well?

If your users need to write really complex queries like subqueries, aggregations with HAVING, or window functions, this isn't the right tool. The filter language is designed for WHERE clauses, not full SQL. For reporting dashboards that need GROUP BY and HAVING, you need a different approach (maybe a BI tool, maybe a custom endpoint with a predefined query set).

If your entities have 200 fields... the auto-generated Swagger docs will be massive and the query builder UI will be overwhelming. Consider splitting into domain-specific search endpoints (/cars/search, /cars/admin-search) with appropriate field sets.

If your team has never used a query builder UI... there's a learning curve. The react-querybuilder component helps (it's visual, not text-based), but some users will prefer typing year > 2020 directly. Spring Filter supports both: the @Filter annotation accepts any valid filter string, whether built by a UI component or typed by hand.

The code, all together

Backend (Spring Boot + Spring Filter):

  • One dependency: com.turkraft.springfilter:jpa:4.0.4
  • One annotation on the controller parameter: @Filter
  • One repository extending JpaSpecificationExecutor
  • Optional: ParseContext for security/tenant/soft-delete injection
  • That's it

Frontend (React + FilterKit):

  • With react-querybuilder: @turkraft/filterkit + @turkraft/filterkit-querybuildertoFilterExpression(query)
  • With TanStack Table: @turkraft/filterkit + @turkraft/filterkit-tanstacktoFilterString(columnFilters)
  • Standard fetch() or useQuery() call either way
  • That's it

The "that's it" part is what I'm happiest about. I've built this pipeline probably six or seven times across different projects. Querydsl, custom JSON schema, manual parameter parsing, Swagger docs by hand, frontend state management for filter values. Every single time it was 500+ lines of boilerplate that broke in subtle ways. This pipeline is tiny compared to what it replaces. And it breaks loudly (parser error, 400 response) rather than silently (wrong results, zero rows, confused users). That alone is worth the switch.


The code for both libraries is on GitHub: FilterKit for the frontend, Spring Filter for the backend. There's a live demo at springfilter-jpa.onrender.com if you want to try the filter syntax without setting anything up.

Top comments (0)