---
title: "Building a Modern Query Builder with React 19 Server Components"
published_at: "2026-02-16T12:11:51+00:00"
modified_at: "2026-02-16T13:53:08+00:00"
url: "https://www.syncfusion.com/blogs/post/build-query-builder-in-react-19"
excerpt: "React 19’s Server Components and Server Actions streamline dynamic filtering with a server-driven architecture. Combine them with Syncfusion Query Builder to implement advanced query logic and optimized rendering."
taxonomy_category:
  - "Next.js"
  - "Query Builder"
  - "React"
  - "React 19"
  - "SQL"
  - "Web Development"
taxonomy_post_tag:
  - "Next.js"
  - "React 19"
  - "React Query Builder"
  - "Server Components"
  - "UI controls"
  - "Web Development"
---

# Building a Modern Query Builder with React 19 Server Components

[Satheeskumar S](https://www.syncfusion.com/blogs/author/satheeskumars)

![Building a Query Builder with React 19 Server Components and Server Actions](https://www.syncfusion.com/blogs/wp-content/uploads/2026/02/Building-a-Query-Builder-with-React-19-Server-Components-and-Server-Actions.jpg)


**TL;DR:** Discover how to build a dynamic query builder in React 19 using Server Components and Server Actions, reducing client-side complexity and bundle size. Users can pick fields, set operators, create AND/OR conditions, and apply advanced filters using a professional UI powered by Syncfusion.

Building dynamic filtering systems the “**old way**” often snowballs into multiple API endpoints, duplicated validation, brittle client state, and large JavaScript bundles. React 19 changes the shape of that problem.

- Traditional flow: Browser → HTTP → API Route → Database → JSON → Browser (every hop adds boilerplate).
- React 19 + Server Actions flow: Browser → Direct Server Action → Database → Direct Response (less code, fewer moving parts).

Pair that with [Syncfusion® React Query Builder](https://www.syncfusion.com/react-components/react-query-builder)
 for a clean, visual rule builder, and you have a complete, production-ready solution.

## What you’ll build

A complete product filtering application where users can:

- Select from available product fields.
- Choose comparison operators.
- Build complex queries with **AND**/** OR** logic.
- Combine multiple conditions for advanced filtering.
- See filtered results instantly in a professional table.

## What is Syncfusion Query Builder?

[Syncfusion Query Builder](https://www.syncfusion.com/react-components/react-query-builder)
 is an enterprise-grade React component that provides an intuitive, visual interface for building complex database queries without requiring SQL knowledge.

Here are the key features you’ll use in this sample:

- Intuitive drag-and-drop rule building.
- Support for **AND**/** OR** conditions.
- Multiple comparison operators (8+ types).
- Nested rule groups for complex logic.
- Real-time validation.
- Fully customizable with Tailwind CSS.

## Core concepts (React 19 + Next.js App Router)

You’ll use React Server Components and Server Actions (invoked via ‘**use server**‘ in Next.js) to move filtering logic to the server while keeping the UI snappy on the client.

### 1. Server components (default)

Run on the server only. They never ship JavaScript to the browser, making them ideal for:

- Accessing the database directly.
- Running backend-only logic.
- Reducing bundle size and improving performance.
- Keeping sensitive operations off the client.

Because Server Components run in a Node.js environment, you can safely execute queries and return rendered HTML to the client.

Here’s how you can do it in code:

JavaScript

```
// No 'use client' = Server Component
export default async function ProductsList() {
    const products = await getProducts();
    return (
        <div>{/* renders on server */}</div>
    );
}
```

### 2. Client components (‘use client’)

Client Components run in the browser, enabling:

- User interactions.
- State management with React hooks.
- Query Builder UI (which needs DOM + events).
- Buttons, inputs, and dynamic UI updates.

Implementation example:

JavaScript

```
'use client';
import { useState } from 'react';
export default function Filter() {
    const [query, setQuery] = useState([]);
    return(
        <div>{/* interactive UI */}</div>
    );
}
```

### 3. Server actions (‘use server’)

Server Actions let you call server-side functions directly from Client Components, without API routes.

They allow:

- Direct database access from UI events.
- Strong TypeScript support.
- Automatic serialization.
- Cleaner architecture (no REST/GraphQL endpoints).

Below is the code you need:

JavaScript

```
'use server';
export async function executeQuery(query) {
    const results = await filterProducts(query);
    return results;
}
```

## Quick setup

This setup section walks through installing Syncfusion components, configuring Next.js, and preparing the folders you’ll use throughout the project.

### Prerequisites

You need an environment that supports:

- Node.js 18.17+ → Required for React 19 + Server Actions.
- npm 9.x+ → Ensures package compatibility.
- A Next.js App Router project (created below).

### Step 1: Create the project and install dependencies

Start by creating the Next.js project, then install the Syncfusion components you need, and finally set up the folder structure for your database, types, components, and server actions. To complete these steps, run the following commands in your terminal:

Bash

```
// Create app
npx create-next-app@latest querybuilder-app --typescript --tailwind –app
cd querybuilder-app

// Install Syncfusion dependencies
npm install @syncfusion/ej2-react-querybuilder @syncfusion/ej2-react-grids @syncfusion/ej2-react-buttons @syncfusion/ej2-react-inputs @syncfusion/ej2-react-dropdowns

// Create folders for your app structure
mkdir -p lib/db lib/types lib/utils app/components app/actions
```

### Step 2: Configure CSS for Syncfusion + Tailwind

You need to add the required style imports for Syncfusion components, and Tailwind requires its directives.

Add this code at the very top of update **app/globals.css**:

app/globals.css

```
@import "@syncfusion/ej2-react-querybuilder/styles/material.css";
@import "@syncfusion/ej2-react-grids/styles/material.css";
@import "@syncfusion/ej2-react-buttons/styles/material.css";
@import "@syncfusion/ej2-react-inputs/styles/material.css";
@import "@syncfusion/ej2-react-dropdowns/styles/material.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
.card {
    @apply bg-white rounded-lg shadow border border-gray-200 p-6; 
}
.btn {
    @apply px-4 py-2 rounded font-medium transition cursor-pointer;
}
.btn-primary {
    @apply bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50;
}
.btn-secondary {
    @apply bg-gray-200 text-gray-800 hover:bg-gray-300;
}
.input-field {
    @apply w-full rounded border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500;
}
.grid-container {
    @apply rounded-lg border border-gray-200 overflow-hidden shadow-sm;
}
```

## Complete file implementations (server‑centric filtering)

Syncfusion Query Builder generates a Query or Predicate that typically works with **Syncfusion DataManager**. But you can’t use DataManager in Server Components; instead, you’ll need to parse the incoming predicates and evaluate them on the server.

### 1. Type definitions

The Product class defines our data structure, so it’s included in `index.ts`. We also added an **APIResponse** class to handle server responses. Since Syncfusion’s built-in Predicate objects contain function properties that can’t be serialized—and therefore can’t be used in Server Components—we introduced a custom Predicate class. The server uses this class to parse and convert the predicates sent from the client-side Query Builder before evaluating them.

Here’s the Product class definition:

lib/types/index.ts

```
export interface Product {
    id: number;
    name: string;
    category: string;
    description?: string;
    price: number;
    costPrice: number;
    stock: number;
    manufacturer: string;
    rating: number;
    status: 'active' | 'inactive' | 'discontinued';
    createdAt: Date;
    updatedAt: Date;
}

export interface ApiResponse<T = unknown> {
    success: boolean;
    data?: T;
    error?: string;
    timestamp: string;
}

export interface Predicate {
    field: string;
    operator:
        | 'equal'
        | 'notequal'
        | 'greaterthan'
        | 'greaterthanorequal'
        | 'lessthan'
        | 'lessthanorequal'
        | 'contains'
        | 'startswith'
        | 'endswith';
    value: string | number | boolean | Date | null;
    ignoreCase?: boolean;
    condition?: 'and' | 'or';        // for grouping
    isComplex?: boolean;
    predicates?: Predicate[];   // for nested AND/OR
}
```

### 2. Database layer

This array holds the entire data collection in memory, allowing us to perform server-side filtering, sorting, paging, and other data operations directly on it.

Code snippet to achieve this:

lib/db/products.ts

```
import { Product } from '@/lib/types';
export const mockProducts: Product[] = [
    {
        id: 1,
        name:'MacBook Pro 16',
        category: 'Laptops',
        description: 'Powerful laptop with Apple Silicon M3 Max',
        price: 3499.99,
        costPrice: 2800,
        stock: 25,
        manufacturer: 'Apple',
        rating: 4.9,
        status: 'active',
        createdAt: new Date('2024-01-15'),
        updatedAt: new Date('2024-01-15'),
    },
    {
        id: 2,
        name: 'Dell XPS 15',
        category: 'Laptops',
        description: 'Premium Windows laptop',
        price: 2799.99,
        costPrice: 2000,
        stock: 40,
        manufacturer: 'Dell',
        rating: 4.7,
        status: 'active',
        createdAt: new Date('2024-01-10'),
        updatedAt: new Date('2024-01-10'),
    },
    {
        id: 3,
        name: 'Sony WH-1000XM5',
        category: 'Audio',
        description: 'Noise-canceling headphones',
        price: 399.99,
        costPrice: 280,
        stock: 80,
        manufacturer: 'Sony',
        rating: 4.9,
        status: 'active',
        createdAt: new Date('2024-01-16'),
        updatedAt: new Date('2024-01-16'),
    },
    {
        id: 4,
        name: 'iPad Air',
        category: 'Tablets',
        description: 'Lightweight tablet with M1',
        price: 599.99,
        costPrice: 450,
        stock: 60,
        manufacturer: 'Apple',
        rating: 4.8,
        status: 'active',
        createdAt: new Date('2024-01-18'),
        updatedAt: new Date('2024-01-18'),
    },
    {
        id: 5,
        name: 'Magic Mouse',
        category: 'Accessories',
        description: 'Wireless mouse',
        price: 79.99,
        costPrice: 45,
        stock: 150,
        manufacturer: 'Apple',
        rating: 4.3,
        status: 'active',
        createdAt: new Date('2024-01-08')
        updatedAt: new Date('2024-01-08'),
    },
    {
        id: 6,
        name: 'Samsung Galaxy Tab S9',
        category: 'Tablets',
        description: 'AMOLED tablet',
        price: 649.99,
        costPrice: 500,
        stock: 45,
        manufacturer: 'Samsung',
        rating: 4.5, status: 'active',
        createdAt: new Date('2024-01-14'),
        updatedAt: new Date('2024-01-14'),
    },
];

export async function getProducts(): Promise<Product[]> {
    await new Promise(r => setTimeout(r, 200));
    return [...mockProducts];
}
```

### 3. Server actions

The `evaluatePredicates` method parses and converts the predicates received from the Syncfusion Query Builder into a server-side format that the filtering logic can process.

Try this in your code:

app/actions.ts

```
'use server';

import { getProducts } from '@/lib/db/products';
import type { Product, ApiResponse } from '@/lib/types';
import type { Predicate } from '@/lib/types';

export async function filterProducts(predicates?: Predicate): Promise<ApiResponse<Product[]>>  {
    try {
        await new Promise(resolve => setTimeout(resolve, 5000));
        const allProducts = await getProducts();

        if (!predicates)
            return {
                success: false,
                error: 'Predicates Not Received',
                timestamp: new Date().toISOString(),
            };

        const list = Array.isArray(predicates) ? predicates : [predicates];
        const filtered = allProducts.filter(item => evaluatePredicates(item, list));

        return {
            success: true,
            data: filtered,
            timestamp: new Date().toISOString(),
        };
    } catch (error) {
        console.error('Filter failed:', error);
        return {
            success: false,
            error: 'Failed to filter products',
            timestamp: new Date().toISOString(),
        };
    }
}

function evaluatePredicates(item: any, predicates: Predicate[]): boolean {
    for (const p of predicates) {
        // Nested group: (A and B) or C
        if (p.isComplex && p.predicates) {
            const subResult = evaluatePredicates(item, p.predicates);
            if (p.condition === 'or' && subResult) return true;
            if (p.condition !== 'or' && !subResult) return false;
            continue;
        }

        let value = item[p.field];
        let target = p.value;

        // Case-insensitive strings
        if (p.ignoreCase && typeof value === 'string' && typeof target === 'string') {
            value = value.toLowerCase();
            target = target.toLowerCase();
        }

        let match = false;
        switch (p.operator) {
            case 'equal': match = value === target; break;
            case 'notequal':         match = value !== target; break;
            case 'greaterthan':
                if (target == null) { match = false; break; }
                match = Number(value) > Number(target); break;
            case 'greaterthanorequal':
                if (target == null) { match = false; break; }
                match = Number(value) >= Number(target); break;
            case 'lessthan':
                if (target == null) { match = false; break; }
                match = Number(value) < Number(target); break;
            case 'lessthanorequal':
                if (target == null) { match = false; break; }
                match = Number(value) <= Number(target); break;
            case 'contains': match = String(value).includes(String(target)); break;
            case 'startswith': match = String(value).startsWith(String(target)); break;
            case 'endswith': match = String(value).endsWith(String(target)); break;
            default: match = true;
        }

        // OR short-circuit
        if (p.condition === 'or' && match) return true;
        // AND fail-fast
        if (p.condition !== 'or' && !match) return false;
    }

    return true; // all ANDs passed
}
```

### 4. Server component

This component is a Next.js server component that disables caching and forces dynamic rendering, fetching fresh product data on each request. It retrieves products from the database, prepares field metadata, and passes the results to your client component.

Below is the code you need:

app/components/QueryBuilderContainer.tsx

```
export const revalidate = 0;
export const dynamic = 'force-dynamic';
import { unstable_noStore as noStore } from 'next/cache';
import QueryBuilderClient from './QueryBuilderClient';
import { getProducts } from '@/lib/db/products';

export default async function QueryBuilderContainer() {
    noStore();
    await new Promise(resolve => setTimeout(resolve, 4000)); //adds a 4-second delay for demo purposes
    const products = await getProducts();
    const metadata = {
        fields: [
            { name: 'id', label: 'ID', type: 'number' },
            { name: 'name', label: 'Product Name', type: 'string' },
            { name: 'category', label: 'Category', type: 'string' },
            { name: 'price', label: 'Price', type: 'number' },
            { name: 'stock', label: 'Stock', type: 'number' },
            { name: 'rating', label: 'Rating', type: 'number' },
            { name: 'status', label: 'Status', type: 'string' }
        ],
        products: products
    };
    return <QueryBuilderClient metadata={metadata} />;
}
```

### 5. Client component

This component combines the **Query Builder**, an ** Apply Filter** button, and the ** Result Table**.

When the user builds a query in the Query Builder and clicks the **Apply Filter** button, the generated predicates are sent to the server. The server evaluates those predicates against the data source and returns the filtered results. The component then updates the UI by displaying the results in the Result Table.

Here’s how you can do it in code:

app/components/QueryBuilderClient.tsx

```
'use client';
import { useRef, useState } from 'react';
import { Product, ApiResponse } from '@/lib/types';
import { filterProducts } from '@/app/actions';
import ResultsTable from './ResultsTable';
import { QueryBuilderComponent, RuleModel } from '@syncfusion/ej2-react-querybuilder';
import { SerializePredicate } from '@/lib/utils/PredicateSerializer';

export default function QueryBuilderClient({ metadata }: any) {
    const [rule, setRule] = useState<RuleModel>({
        condition: 'and',
        rules: [{}],
    });

    const columns = metadata.fields.map((field: any) => ({
        field: field.name,
        label: field.label,
        type: field.type,
    }));

    const ds = metadata.products;

    const [results, setResults] = useState<Product[]>([]);
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState<string | null>(null);
    const [executed, setExecuted] = useState(false);
    const queryBuilderRef = useRef<QueryBuilderComponent>(null);

    const handleApplyFilter = async () => {
        setLoading(true);
        setError(null);
        let predicate: any = queryBuilderRef.current?.getPredicate(queryBuilderRef.current?.getValidRules());
        let serializedPredicate = SerializePredicate(predicate);
        const response: ApiResponse<Product[]> = await filterProducts(serializedPredicate);
        if (response.success) {
            setResults(response.data || []);
        } else {
            setError(response.error || 'An unknown error occurred.');
        }
        setExecuted(true);
        setLoading(false);
    };

    const handleReset = () => {
        setRule({ condition: 'and', rules: [{}] });
        setResults([]);
        setError(null);
        setExecuted(false);
    };

    return (
        <div className="space-y-6">
            {error && (
                <div className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800">
                    <strong>Error:</strong> {error}
                </div>
            )}
            <QueryBuilderComponent ref={queryBuilderRef}  rule={rule} onChange={setRule} columns={columns} dataSource={ds} />
            <div className="flex gap-3">
                <button onClick={handleApplyFilter} disabled={loading} className="e-btn btn-primary">
                    {loading ? 'Filtering...' : 'Apply Filter' }
                </button>
                <button onClick={handleReset} className="e-btn btn-primary">Reset</button>
            </div>
            {executed && (
                <div className="mt-8 pt-6 border-t">
                    <h3 className="text-lg font-semibold mb-4">Results ({results.length})</h3>
                    {results.length === 0 ? (
                        <p className="text-center py-8 text-gray-600">No products match your criteria.</p>
                    ) : (
                        <ResultsTable products={results} />
                    )}
                </div>
            )}
            {loading && (
                <div className="flex justify-center py-8">
                    <div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-blue-600" />
                </div>
            )}
        </div>
    );
}
```

You can use the `SerializePredicate` method to clean the predicates generated by the Query Builder by removing non-serializable or unnecessary properties. This step is essential because directly sending the raw predicates to the server will cause serialization failures. By stripping out unsupported fields before making the request, the payload remains clean and safe for transmission.

Add this to your **`PredicateSerializer.ts`** file:

app/utils/PredicateSerializer.ts

```
import { Predicate } from '@syncfusion/ej2-data';

export function SerializePredicate(pred: Predicate | Predicate[]): any {
    if (Array.isArray(pred)) {
        return pred.map(serializeSingle);
    }
    return serializeSingle(pred);
}

function serializeSingle(p: any): any {
    if (p.isComplex && p.predicates) {
        return {
            condition: p.condition || 'and',
            isComplex: true,
            predicates: p.predicates.map(serializeSingle),
        };
    }

    return {
        field: p.field,
        operator: p.operator,
        value: p.value,
        ignoreCase: p.ignoreCase ?? false,
        condition: p.condition || 'and',
    };
}
```

### 6. Results Table

This component is responsible for rendering the filtered data in a tabular format.

Try this in your code:

app/components/ResultsTable.tsx

```
'use client';
import { Product } from '@/lib/types';

export default function ResultsTable({ products }: { products: Product[] }) {
    return (
        <div className="grid-container overflow-x-auto">
            <table className="w-full">
                <thead>
                    <tr className="bg-gray-100 border-b-2 border-gray-300">
                        <th className="px-4 py-3 text-left font-semibold">Name</th>
                        <th className="px-4 py-3 text-left font-semibold">Category</th>
                        <th className="px-4 py-3 text-left font-semibold">Price</th>
                        <th className="px-4 py-3 text-left font-semibold">Stock</th>
                        <th className="px-4 py-3 text-left font-semibold">Rating</th>
                        <th className="px-4 py-3 text-left font-semibold">Manufacturer</th>
                        <th className="px-4 py-3 text-left font-semibold">Status</th>
                    </tr>
                </thead>
                <tbody>
                    {products.map((product, index) => (
                        <tr key={product.id} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
                            <td className="px-4 py-3 font-medium">{product.name}</td>
                            <td className="px-4 py-3">
                                <span className="inline-block px-3 py-1 rounded-full text-sm bg-blue-100 text-blue-800">
                                    {product.category}
                                </span>
                            </td>
                            <td className="px-4 py-3 font-semibold">
                                ${product.price.toFixed(2)}
                            </td>
                            <td className="px-4 py-3">{product.stock} units</td>
                            <td className="px-4 py-3">⭐ {product.rating}</td>
                            <td className="px-4 py-3">{product.manufacturer}</td>
                            <td className="px-4 py-3">
                                <span className={`inline-block px-3 py-1 rounded text-sm font-medium ${
                                    product.status === 'active' ? 'bg-green-100 text-green-800' :
                                    product.status === 'inactive' ? 'bg-yellow-100 text-yellow-800' :
                                    'bg-red-100 text-red-800'
                                }`}>
                                    {product.status}
                                </span>
                            </td>
                        </tr>
                    ))}
                </tbody>
            </table>
        </div>
    );
}
```

### 7. Home component

The Home component serves as the main page, rendering a heading and lazy loading the **`QueryBuilderContainer`** inside a Suspense boundary. While the Query Builder loads, a Skeleton component displays an animated placeholder with a “**Loading…**” message for a smooth user experience.

Add this to your **app/page.tsx**:

app/page.tsx

```
import { Suspense } from 'react';
import QueryBuilderContainer from './components/QueryBuilderContainer';

export default function Home() {
    return (
        <div className="space-y-8">
            <section>
                <h2 className="text-xl font-semibold mb-2">Build Dynamic Queries</h2>
                <p className="text-gray-600">Select fields, operators, and values to filter products.</p>
            </section>

            <section className="card">
                <Suspense fallback={<div className="animate-pulse space-y-4"><div className="h-20 bg-gray-200 rounded" /></div>}>
                    <QueryBuilderContainer />
                </Suspense>
            </section>
        </div>
    );
}
```

Once you’ve finished configuring your setup and adding the required code changes, you can launch the application by running the following command:

Bash

```
npm run build // To build the application
npm run start // To serve the application
```

Now open a browser and navigate to “[http://localhost:3000](http://localhost:3000)
” to see the application.

## GitHub

You can download the complete Query Builder sample built with React 19 Server Components from this GitHub [demo](https://github.com/SyncfusionExamples/Syncfusion-Query-Builder-with-React-19-Server-Components-Complete-Guide)
.

## Frequently Asked Questions

What is the recommended architecture for using Syncfusion Query Builder with React Server Components?Use the Query Builder as a client component (`'use client'`) for UI interactivity; send its serialized predicates to the server via Server Actions (`'use server'`) where server components can apply filtering directly to your data source (**no DataManager on server**).

How do I send predicates from the client to the server safely (without serialization issues)?Strip non-serializable properties (functions) from Syncfusion predicates using a serialization helper (e.g., a SerializePredicate method), convert to a plain JSON-friendly shape, then parse on the server into a custom Predicate class before evaluation.

Can I use Syncfusion DataManager or raw Query Builder predicates directly in Server Components?No. Syncfusion DataManager works only on the client-side, and raw predicate objects contain functions that aren’t serializable. Implement server-side filtering logic that accepts the serialized predicate shape and evaluates it against your dataset.


## Conclusion

Thank you for reading! By following this guide rooted in proven patterns from Next.js Server Components, you can build a fast, scalable, and high‑performance filtering application. The [Syncfusion React Query Builder](https://www.syncfusion.com/react-components/react-query-builder)
 delivers a rich, interactive client-side experience, while all data fetching and filtering run efficiently on the server using Server Components. This approach reduces your client bundle size, improves response times, and ensures a secure and optimized architecture that blends powerful UI interactivity with robust server-side logic.

Ready to take the next step? Start integrating this architecture into your application today and experience the performance boost firsthand.

If you’re a Syncfusion user, you can download the setup from the [license and downloads](https://www.syncfusion.com/sales/teamlicense)
 page. Otherwise, you can download a free [30-day trial](https://www.syncfusion.com/downloads/)
.

You can also contact us through our [support forum](https://www.syncfusion.com/forums)
, [support portal](https://support.syncfusion.com/)
, or [feedback portal](https://www.syncfusion.com/feedback)
. We are always happy to assist you!w

## Related Blogs



[What’s New in React Query Builder: 2024 Volume 1](https://www.syncfusion.com/blogs/post/react-query-builder-2024-volume-1)



[Building an Optimistic UI Task Management App with React 19 and Next.js](https://www.syncfusion.com/blogs/post/task-management-app-react19-nextjs)



[What’s New in React 19?](https://www.syncfusion.com/blogs/post/whats-new-in-react-19)



[How to Join Two Tables Using JavaScript Query Builder?](https://www.syncfusion.com/blogs/post/join-tables-in-javascript-query-builder)
