Build a Query Builder in React 19 with Server Components and Server Actions | Syncfusion Blogs
Loader
Building a Query Builder with React 19 Server Components and Server Actions

Summarize this blog post with:

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 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 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:

// 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:

'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:

'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:

// 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:

@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:

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:

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:

'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:

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:

'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:

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:

'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:

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:

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

Now open a browser and navigate to “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.

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.

Explore the endless possibilities with Syncfusion’s outstanding React UI components.

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 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 page. Otherwise, you can download a free 30-day trial.

You can also contact us through our support forumsupport portal, or feedback portal. We are always happy to assist you!w

Be the first to get updates

Satheeskumar SSatheeskumar S profile icon

Meet the Author

Satheeskumar S

Satheeskumar works as a product manager at Syncfusion, where he specializes in the development of web components with cutting-edge technologies. He is interested in the latest web technologies and provides solutions for great products.

Leave a comment