• Skip to secondary menu
  • Skip to main content
  • Skip to primary sidebar
  • Home
  • Projects
  • Products
  • Themes
  • Tools
  • Request for Quote

Vengala Vinay

Having 12+ Years of Experience in Software Development

  • Home
  • WordPress
  • PHP
    • Codeigniter
  • Django
  • Magento
  • Selenium
  • Server
Home » Step-by-Step Guide to building a custom Elasticsearch search bar block for Gutenberg using REST API custom routes

Step-by-Step Guide to building a custom Elasticsearch search bar block for Gutenberg using REST API custom routes

Leveraging Elasticsearch for Advanced WordPress Search with a Custom Gutenberg Block

This guide details the construction of a custom Gutenberg block for WordPress that interfaces with Elasticsearch via custom REST API routes. This approach bypasses the limitations of standard WordPress search, offering superior performance, relevance, and flexibility for large or complex content repositories. We’ll cover backend setup, custom route creation, and frontend block development.

Prerequisites and Setup

Before diving into code, ensure you have the following:

  • A running Elasticsearch instance accessible from your WordPress server.
  • The official Elasticsearch for WordPress plugin installed and configured to index your content.
  • A local development environment for WordPress (e.g., Local by Flywheel, Docker).
  • Basic understanding of PHP, JavaScript (React), and WordPress plugin development.

Backend: Custom REST API Routes

We need to expose an endpoint that our Gutenberg block can query to fetch search results from Elasticsearch. This will be done using WordPress’s custom REST API routes.

Registering the Route

Create a new PHP file (e.g., custom-search-api.php) within your plugin’s directory and include the following code to register a new route:

<?php
/**
 * Plugin Name: Custom Elasticsearch Search Block
 * Description: Adds a custom Gutenberg block for Elasticsearch search.
 * Version: 1.0.0
 * Author: Your Name
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit; // Exit if accessed directly.
}

/**
 * Register custom REST API route for Elasticsearch search.
 */
function cesb_register_search_route() {
    register_rest_route( 'cesb/v1', '/search', array(
        'methods'  => WP_REST_Server::READABLE,
        'callback' => 'cesb_handle_search_request',
        'permission_callback' => '__return_true', // Adjust permissions as needed for production
    ) );
}
add_action( 'rest_api_init', 'cesb_register_search_route' );

/**
 * Callback function to handle Elasticsearch search requests.
 *
 * @param WP_REST_Request $request Full data about the request.
 * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
 */
function cesb_handle_search_request( WP_REST_Request $request ) {
    $query = $request->get_param( 'query' );

    if ( empty( $query ) ) {
        return new WP_Error( 'missing_param', 'Search query parameter is required.', array( 'status' => 400 ) );
    }

    // Placeholder for actual Elasticsearch query logic
    // In a real-world scenario, you'd use an Elasticsearch client library
    // or the Elasticsearch for WordPress plugin's API to query Elasticsearch.
    $results = cesb_query_elasticsearch( $query );

    if ( is_wp_error( $results ) ) {
        return $results;
    }

    return new WP_REST_Response( $results, 200 );
}

/**
 * Placeholder function to simulate Elasticsearch query.
 * Replace this with actual Elasticsearch client interaction.
 *
 * @param string $query The search term.
 * @return array|WP_Error Search results or WP_Error.
 */
function cesb_query_elasticsearch( $query ) {
    // This is a simplified example. In production, you would:
    // 1. Use an Elasticsearch PHP client (e.g., elasticsearch-php).
    // 2. Construct a proper Elasticsearch query (e.g., match, multi_match).
    // 3. Handle authentication and connection details.
    // 4. Parse the Elasticsearch response.

    // Example using a hypothetical function from Elasticsearch for WordPress plugin
    // if ( class_exists( 'ElasticPress\Query' ) ) {
    //     $ep_query = new \ElasticPress\Query();
    //     $ep_query->query( [
    //         'multi_match' => [
    //             'query' => sanitize_text_field( $query ),
    //             'fields' => [ 'post_title^3', 'post_content' ], // Boost title
    //         ]
    //     ] );
    //     $ep_query->size( 10 ); // Limit results
    //     $ep_query->post_type( 'post' ); // Specify post types
    //     $ep_query->post_status( 'publish' );

    //     $search_results = $ep_query->get();

    //     if ( ! empty( $search_results['hits']['hits'] ) ) {
    //         $formatted_results = array_map( function( $hit ) {
    //             return [
    //                 'id' => $hit['_id'],
    //                 'title' => $hit['_source']['post_title'],
    //                 'url' => get_permalink( $hit['_id'] ), // Assuming _id is post ID
    //                 'excerpt' => wp_trim_words( $hit['_source']['post_content'], 20, '...' ),
    //             ];
    //         }, $search_results['hits']['hits'] );
    //         return $formatted_results;
    //     } else {
    //         return [];
    //     }
    // }

    // Fallback for demonstration if ElasticPress is not active or for testing
    $mock_data = [
        [
            'id' => 1,
            'title' => 'Elasticsearch Integration Example',
            'url' => '#',
            'excerpt' => 'This is a sample post about integrating Elasticsearch with WordPress.',
        ],
        [
            'id' => 2,
            'title' => 'Gutenberg Block Development Guide',
            'url' => '#',
            'excerpt' => 'Learn how to build custom Gutenberg blocks for your WordPress site.',
        ],
    ];

    // Simulate filtering based on query
    $filtered_results = array_filter($mock_data, function($item) use ($query) {
        return stripos($item['title'], $query) !== false || stripos($item['excerpt'], $query) !== false;
    });

    return array_values($filtered_results);
}

Explanation:

  • cesb_register_search_route(): Hooks into rest_api_init to register our custom endpoint at /wp-json/cesb/v1/search.
  • WP_REST_Server::READABLE: Specifies that this endpoint responds to GET requests.
  • cesb_handle_search_request(): The callback function that processes the request. It retrieves the query parameter.
  • cesb_query_elasticsearch(): This is a crucial placeholder. In a production environment, you would integrate with the Elasticsearch for WordPress plugin’s API (e.g., using ElasticPress\Query) or a dedicated Elasticsearch client library (like elasticsearch-php) to perform the actual search against your Elasticsearch cluster. The example includes commented-out code demonstrating how you might use ElasticPress.
  • __return_true: For simplicity, permissions are set to allow anyone to access this endpoint. For production, implement proper authentication and authorization checks.

Frontend: Gutenberg Block Development

Now, let’s build the Gutenberg block. This involves creating JavaScript files (using React) to define the block’s editor interface and its frontend rendering.

Block Registration and Editor Component

Create a new directory for your block (e.g., custom-elasticsearch-search) inside your plugin’s /blocks/ folder. Inside this directory, create index.js and editor.scss.

// custom-elasticsearch-search/index.js
import { registerBlockType } from '@wordpress/blocks';
import { useState, useEffect } from '@wordpress/element';
import { TextControl, Spinner } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import './editor.scss';

const Edit = ( { attributes, setAttributes } ) => {
    const [ query, setQuery ] = useState( '' );
    const [ results, setResults ] = useState( [] );
    const [ isLoading, setIsLoading ] = useState( false );
    const [ error, setError ] = useState( null );

    // Debounce search to avoid excessive API calls
    useEffect( () => {
        const handler = setTimeout( () => {
            if ( query.length > 2 ) { // Only search if query is at least 3 characters
                searchElasticsearch( query );
            } else {
                setResults( [] ); // Clear results if query is too short
            }
        }, 500 ); // 500ms debounce delay

        return () => {
            clearTimeout( handler );
        };
    }, [ query ] );

    const searchElasticsearch = async ( searchTerm ) => {
        setIsLoading( true );
        setError( null );
        try {
            const response = await fetch( `/wp-json/cesb/v1/search?query=${ encodeURIComponent( searchTerm ) }` );
            if ( ! response.ok ) {
                throw new Error( `HTTP error! status: ${ response.status }` );
            }
            const data = await response.json();
            setResults( data );
        } catch ( e ) {
            console.error( "Search failed:", e );
            setError( __( 'Search could not be completed. Please try again later.', 'custom-search-block' ) );
            setResults( [] );
        } finally {
            setIsLoading( false );
        }
    };

    const handleQueryChange = ( newQuery ) => {
        setQuery( newQuery );
        // Update attributes if you need to save the last query or other settings
        // setAttributes( { lastQuery: newQuery } );
    };

    return (
        <div className="custom-elasticsearch-search-editor">
            <TextControl
                label={ __( 'Search', 'custom-search-block' ) }
                value={ query }
                onChange={ handleQueryChange }
                placeholder={ __( 'Enter your search term...', 'custom-search-block' ) }
            />
            { isLoading && <Spinner /> }
            { error && <p className="error-message">{ error }</p> }
            { ! isLoading && ! error && results.length > 0 && (
                <ul className="search-results">
                    { results.map( ( result ) => (
                        <li key={ result.id }>
                            <a href={ result.url }>{ result.title }</a>
                            <p>{ result.excerpt }</p>
                        </li>
                    ) ) }
                </ul>
            ) }
            { ! isLoading && ! error && query.length > 0 && results.length === 0 && (
                <p>{ __( 'No results found.', 'custom-search-block' ) }</p>
            ) }
        </div>
    );
};

registerBlockType( 'cesb/search', {
    title: __( 'Elasticsearch Search', 'custom-search-block' ),
    icon: 'search',
    category: 'widgets', // Or 'common', 'design', etc.
    attributes: {
        // Define attributes if you need to save state (e.g., last query)
        // lastQuery: {
        //     type: 'string',
        //     default: '',
        // },
    },
    edit: Edit,
    save: () => {
        // The frontend rendering will be handled by PHP or a separate JS file
        // For dynamic blocks, return null or a placeholder.
        // For static blocks, return the JSX that should be saved to post_content.
        // Since this is dynamic and relies on JS for fetching, we return null.
        return null;
    },
} );
/* custom-elasticsearch-search/editor.scss */
.custom-elasticsearch-search-editor {
    padding: 15px;
    border: 1px solid #ddd;
    border-radius: 4px;

    .components-text-control__input {
        margin-bottom: 10px;
    }

    .search-results {
        list-style: none;
        padding: 0;
        margin-top: 10px;

        li {
            margin-bottom: 10px;
            border-bottom: 1px solid #eee;
            padding-bottom: 5px;

            a {
                font-weight: bold;
                text-decoration: none;
                color: #333;
            }

            p {
                font-size: 0.9em;
                color: #555;
                margin-top: 5px;
            }
        }
    }

    .error-message {
        color: red;
        font-weight: bold;
    }
}

Explanation:

  • Imports: We import necessary components from @wordpress/blocks, @wordpress/element, and @wordpress/components.
  • Edit Component: This React component defines the block’s appearance and behavior in the Gutenberg editor.
  • State Management: useState hooks manage the search query, results, loading state, and any errors.
  • Debounced Search: The useEffect hook implements debouncing for the search input. This ensures that the API is only called after the user has stopped typing for a short period (500ms), preventing excessive requests.
  • searchElasticsearch Function: This asynchronous function fetches data from our custom REST API route (/wp-json/cesb/v1/search). It handles loading states and error reporting.
  • TextControl: A Gutenberg component for the input field.
  • Results Display: The component conditionally renders a list of search results, a spinner while loading, or an error message.
  • registerBlockType: Registers the block with WordPress under the namespace cesb/search.
  • save Function: For dynamic blocks like this one, where the content is fetched client-side, the save function should return null. WordPress will then render the block using the render_callback in PHP (which we’ll define next).

Frontend Rendering (Dynamic Block)

Since our block fetches data dynamically using JavaScript, we need to register a render_callback in PHP to handle its output on the frontend. Add this to your custom-search-api.php file:

/**
 * Render callback for the custom Elasticsearch search block.
 * Enqueues necessary scripts for frontend rendering.
 *
 * @param array $attributes Block attributes.
 * @return string HTML output.
 */
function cesb_render_search_block( $attributes ) {
    // Enqueue the block's frontend JavaScript if not already loaded.
    // Ensure you have a build process that compiles JS and CSS.
    // For simplicity, we'll assume a file named 'frontend.asset.php' is generated by @wordpress/scripts.
    $asset_file = include( plugin_dir_path( __FILE__ ) . 'build/frontend.asset.php' );

    wp_enqueue_script(
        'cesb-search-frontend',
        plugin_dir_url( __FILE__ ) . 'build/frontend.js',
        $asset_file['dependencies'],
        $asset_file['version']
    );

    // Add inline script to pass API URL if needed, or rely on JS to construct it.
    // wp_add_inline_script( 'cesb-search-frontend', 'const cesbApiSettings = { apiUrl: "' . esc_url( rest_url( 'cesb/v1/search' ) ) . '" };', 'before' );

    // The actual rendering will be handled by the JavaScript component.
    // We return a placeholder div with an ID that the JS can target.
    return '<div class="wp-block-cesb-search" id="cesb-search-block-instance"></div>';
}

/**
 * Register the block type with a render callback.
 */
function cesb_register_block() {
    register_block_type( 'cesb/search', array(
        'editor_script' => 'cesb-search-block-editor', // Script handle for the editor
        'editor_style'  => 'cesb-search-block-editor-style', // Style handle for the editor
        'render_callback' => 'cesb_render_search_block',
        // 'attributes' => array( // Define attributes here if needed for saving
        //     'lastQuery' => array(
        //         'type' => 'string',
        //         'default' => '',
        //     ),
        // ),
    ) );
}
add_action( 'init', 'cesb_register_block' );

/**
 * Enqueue editor scripts and styles.
 */
function cesb_enqueue_editor_assets() {
    $asset_file = include( plugin_dir_path( __FILE__ ) . 'build/index.asset.php' );

    wp_enqueue_script(
        'cesb-search-block-editor',
        plugin_dir_url( __FILE__ ) . 'build/index.js',
        $asset_file['dependencies'],
        $asset_file['version']
    );

    wp_enqueue_style(
        'cesb-search-block-editor-style',
        plugin_dir_url( __FILE__ ) . 'build/index.css',
        array( 'wp-edit-blocks' ),
        $asset_file['version']
    );
}
add_action( 'enqueue_block_editor_assets', 'cesb_enqueue_editor_assets' );

Explanation:

  • cesb_render_search_block(): This function is called when the block is rendered on the frontend.
  • Asset Enqueuing: It enqueues the compiled JavaScript (frontend.js) and its dependencies. The build/frontend.asset.php file is typically generated by build tools like @wordpress/scripts and contains versioning and dependency information.
  • Placeholder Div: The function returns a simple div. The frontend JavaScript will find this div (e.g., by its ID or class) and mount the React search interface into it, making the API calls.
  • cesb_register_block(): This function registers the block type using register_block_type, linking the editor script/style and the render callback.
  • cesb_enqueue_editor_assets(): This function enqueues the necessary scripts and styles specifically for the Gutenberg editor view.

Build Process

To compile your JavaScript and SCSS files into the format WordPress expects (e.g., build/index.js, build/index.css, build/frontend.js, etc.), you’ll need a build process. The recommended way is to use @wordpress/scripts.

1. Install Node.js and npm/yarn if you haven’t already.

2. In your plugin’s root directory (where custom-search-api.php resides), run:

npm init -y
npm install @wordpress/scripts --save-dev
# or
yarn init -y
yarn add @wordpress/scripts --dev

3. Add build scripts to your package.json:

{
  "name": "custom-elasticsearch-search-plugin",
  "version": "1.0.0",
  "scripts": {
    "build": "wp-scripts build",
    "start": "wp-scripts start",
    "packages-update": "wp-scripts packages-update"
  },
  "devDependencies": {
    "@wordpress/scripts": "^26.0.0" // Use the latest version
  }
}

4. Create a src directory in your plugin’s root. Move your block’s JavaScript (index.js) and SCSS (editor.scss) into src/. You’ll also need a separate entry point for the frontend JavaScript, let’s call it src/frontend.js.

// src/frontend.js
import { render } from '@wordpress/element';
import { useState, useEffect } from '@wordpress/element';
import { Spinner } from '@wordpress/components';
import { __ } from '@wordpress/i18n';

// Simple component to render search results on the frontend
const FrontendSearch = ( { apiFetchUrl } ) => {
    const [ query, setQuery ] = useState( '' );
    const [ results, setResults ] = useState( [] );
    const [ isLoading, setIsLoading ] = useState( false );
    const [ error, setError ] = useState( null );

    useEffect( () => {
        const handler = setTimeout( () => {
            if ( query.length > 2 ) {
                searchElasticsearch( query );
            } else {
                setResults( [] );
            }
        }, 500 );

        return () => {
            clearTimeout( handler );
        };
    }, [ query ] );

    const searchElasticsearch = async ( searchTerm ) => {
        setIsLoading( true );
        setError( null );
        try {
            // Use the provided API fetch URL
            const response = await fetch( `${ apiFetchUrl }?query=${ encodeURIComponent( searchTerm ) }` );
            if ( ! response.ok ) {
                throw new Error( `HTTP error! status: ${ response.status }` );
            }
            const data = await response.json();
            setResults( data );
        } catch ( e ) {
            console.error( "Search failed:", e );
            setError( __( 'Search could not be completed. Please try again later.', 'custom-search-block' ) );
            setResults( [] );
        } finally {
            setIsLoading( false );
        }
    };

    const handleQueryChange = ( e ) => {
        setQuery( e.target.value );
    };

    return (
        <div className="custom-elasticsearch-search-frontend">
            <input
                type="search"
                value={ query }
                onChange={ handleQueryChange }
                placeholder={ __( 'Search...', 'custom-search-block' ) }
                aria-label={ __( 'Search', 'custom-search-block' ) }
            />
            { isLoading && <Spinner /> }
            { error && <p className="error-message">{ error }</p> }
            { ! isLoading && ! error && results.length > 0 && (
                <ul className="search-results">
                    { results.map( ( result ) => (
                        <li key={ result.id }>
                            <a href={ result.url }>{ result.title }</a>
                            <p>{ result.excerpt }</p>
                        </li>
                    ) ) }
                </ul>
            ) }
             { ! isLoading && ! error && query.length > 0 && results.length === 0 && (
                <p>{ __( 'No results found.', 'custom-search-block' ) }</p>
            ) }
        </div>
    );
};

// Find all instances of the block's placeholder div and render the component into them.
document.addEventListener( 'DOMContentLoaded', () => {
    const blockElements = document.querySelectorAll( '.wp-block-cesb-search' ); // Match the block's registered name
    blockElements.forEach( ( element ) => {
        // Get the API URL, potentially passed via inline script or hardcoded if stable
        // For robustness, it's better to pass it via wp_add_inline_script in PHP.
        // Assuming the API URL is stable or constructed here:
        const apiUrl = '/wp-json/cesb/v1/search'; // Adjust if your API URL is different

        render( <FrontendSearch apiFetchUrl={ apiUrl } />, element );
    } );
} );

5. Run the build commands:

npm run build
# or
yarn build

This will create the build/ directory containing compiled assets. Ensure your custom-search-api.php correctly references these files (e.g., using plugin_dir_url( __FILE__ ) and the generated asset files).

Deployment and Usage

1. Place the entire plugin folder into your WordPress installation’s wp-content/plugins/ directory.

2. Activate the “Custom Elasticsearch Search Block” plugin from the WordPress admin panel.

3. Go to the WordPress editor for a post or page.

4. Search for the “Elasticsearch Search” block and add it to your content.

5. As you type in the editor, the block will query Elasticsearch (via your custom API route) and display results.

Further Enhancements and Considerations

  • Error Handling: Implement more robust error handling and user feedback for API failures.
  • Security: Refine the permission_callback for the REST API route to ensure only authorized users can access it, especially if sensitive data is involved. Consider nonces for frontend requests.
  • Styling: Add more comprehensive CSS for both the editor and frontend views. Consider using @wordpress/components for editor styling consistency.
  • Configuration: Allow users to configure Elasticsearch index, fields to search, boost settings, etc., via block attributes and the block inspector controls.
  • Performance: Optimize Elasticsearch queries. Implement caching strategies on the WordPress side if necessary.
  • Accessibility: Ensure the input field and results are accessible (e.g., proper ARIA attributes, keyboard navigation).
  • Internationalization: Use __() and _e() for all user-facing strings.
  • Elasticsearch Client: For complex queries or direct interaction, integrate a robust Elasticsearch client library (e.g., elasticsearch-php) within your cesb_query_elasticsearch function, rather than relying solely on the placeholder or ElasticPress plugin’s abstraction if more control is needed.

Primary Sidebar

A little about the Author

Having 12+ Years of Experience in Software Development, Vinay is a principal software architect, senior systems engineer, and elite technical consultant. He specializes in bespoke PHP/WordPress development, high-performance Magento 2 & Shopify architectures, custom plugin/theme development from scratch, and legacy code modernization (including VB6, VB.NET, PyQt, and Crystal Reports). Known for solving complex database bottlenecks, speed optimization (Core Web Vitals), and advanced security code auditing, Vinay engineers production-ready systems designed to scale under heavy concurrent load conditions.



Chat on WhatsApp

Recent Posts

  • WordPress Development Recipe: Efficient binary storage and retrieval in custom tables using Match expressions
  • Debugging Guide: Diagnosing REST API CORS authorization failures in multi-site network environments with modern tools
  • Troubleshooting PHP-FPM child process pool exhaustion in production when using modern Genesis child themes wrappers
  • Advanced Diagnostics: Identifying and fixing theme asset blocking in FSE Block Themes layouts
  • WordPress Development Recipe: High-efficiency server-side rendering for Gutenberg blocks using Strongly typed objects

Categories

  • apache (1)
  • Business & Monetization (390)
  • Centos (4)
  • Comparisons & Decision Making (55)
  • Debian (2)
  • Debugging & Troubleshooting (658)
  • Desktop Applications (14)
  • DevOps (7)
  • DevOps & Cloud Scaling (962)
  • Django (1)
  • Laravel (4)
  • Migration & Architecture (192)
  • Mobile Applications (24)
  • MySQL (1)
  • Performance & Optimization (872)
  • PHP (5)
  • PHP Development (38)
  • Plugins & Themes (244)
  • Programming Languages (9)
  • Python (20)
  • Ruby on Rails (1)
  • Security & Compliance (639)
  • SEO & Growth (492)
  • Server (23)
  • Ubuntu (9)
  • VB6 & VB.NET (8)
  • Web Applications & Frontend (19)
  • Web Assembly (Wasm) (2)
  • WordPress (22)
  • WordPress Plugin Development (5)
  • WordPress Plugin Development (4)
  • WordPress Plugin Development (330)
  • WordPress Theme Development (357)

Recent Posts

  • WordPress Development Recipe: Efficient binary storage and retrieval in custom tables using Match expressions
  • Debugging Guide: Diagnosing REST API CORS authorization failures in multi-site network environments with modern tools
  • Troubleshooting PHP-FPM child process pool exhaustion in production when using modern Genesis child themes wrappers

Top Categories

  • DevOps & Cloud Scaling (962)
  • Performance & Optimization (872)
  • Debugging & Troubleshooting (658)
  • Security & Compliance (639)
  • SEO & Growth (492)
  • Business & Monetization (390)

Our Products

  • ERP & LMS Systems (4)
  • Directories & Marketplaces (4)
  • Healthcare Portals (3)
  • Point of Sale (POS) (2)
  • E-Commerce Engines (2)

Our Services

  • E-Commerce Development (10)
  • WordPress Development (8)
  • Python & Desktop GUI (7)
  • General Consulting (7)
  • Legacy Modernization (5)
  • Mobile App Development (4)

Copyright © 2026 · Vinay Vengala