• 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 Alpine.js lightweight states

Step-by-Step Guide to building a custom Elasticsearch search bar block for Gutenberg using Alpine.js lightweight states

Prerequisites and Project Setup

Before diving into the Gutenberg block development, ensure you have a local WordPress development environment set up. This typically involves:

  • A local server (e.g., Local by Flywheel, Docker with a WordPress image, or a LAMP/LEMP stack).
  • PHP 7.4+ and MySQL 5.7+ installed.
  • Node.js and npm (or yarn) for asset compilation.
  • A WordPress installation with the Gutenberg editor enabled.
  • An Elasticsearch instance accessible from your WordPress site. For local development, you can use Docker:

To run Elasticsearch locally via Docker, use the following command:

docker run -d --name elasticsearch -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" elasticsearch:7.17.10

This command pulls and runs the Elasticsearch 7.17.10 image, exposing the necessary ports. We’ll use version 7.17.10 as it’s a widely adopted and stable version compatible with many Elasticsearch plugins.

Elasticsearch Indexing Strategy

For an e-commerce site, you’ll want to index product data. This typically includes product title, description, price, categories, and any other searchable attributes. A basic Elasticsearch mapping for products might look like this:

{
  "mappings": {
    "properties": {
      "id": { "type": "integer" },
      "title": { "type": "text", "analyzer": "english" },
      "description": { "type": "text", "analyzer": "english" },
      "price": { "type": "float" },
      "categories": { "type": "keyword" },
      "image_url": { "type": "keyword" }
    }
  }
}

You’ll need a mechanism to populate this index. For WordPress, this often involves a custom plugin that hooks into post save actions (for custom post types like ‘products’) or uses WP-CLI commands for initial bulk indexing. The indexing process itself is beyond the scope of this guide but is a critical prerequisite.

Gutenberg Block Structure and Registration

We’ll create a custom Gutenberg block. This involves a PHP file for server-side registration and rendering, and JavaScript files for the editor interface. Start by creating a new plugin or adding to an existing one. Inside your plugin directory (e.g., wp-content/plugins/my-custom-blocks/), create the following structure:

my-custom-blocks/
├── my-custom-blocks.php
├── build/
│   ├── index.js
│   └── index.asset.php
└── src/
    ├── index.js
    └── block.json

The block.json file defines the block’s metadata:

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "my-custom-blocks/elasticsearch-search",
  "version": "0.1.0",
  "title": "Elasticsearch Search Bar",
  "category": "widgets",
  "icon": "search",
  "description": "A search bar powered by Elasticsearch.",
  "attributes": {
    "placeholderText": {
      "type": "string",
      "default": "Search products..."
    }
  },
  "editorScript": "file:./build/index.js",
  "editorStyle": "file:./build/index.css",
  "style": "file:./build/style-index.css",
  "render": "file:./render.php"
}

The my-custom-blocks.php file registers the block type and enqueues necessary scripts:

<?php
/**
 * Plugin Name: My Custom Elasticsearch Blocks
 * Description: Adds custom blocks, including an Elasticsearch search bar.
 * Version: 1.0.0
 * Author: Your Name
 */

function my_custom_blocks_register_block() {
    register_block_type( __DIR__ . '/build' );
}
add_action( 'init', 'my_custom_blocks_register_block' );

// Add a render callback for the block to handle server-side rendering.
// This is an alternative to the 'render' key in block.json if more complex logic is needed.
// For this example, we'll rely on the 'render' key pointing to render.php.
// If you need dynamic data fetched server-side for the editor, you'd use this.
/*
function my_custom_blocks_render_search_block( $attributes ) {
    // This function would typically be in render.php or called from it.
    // For simplicity, we'll let block.json handle the render.
    return '';
}
*/

Frontend and Editor JavaScript with Alpine.js

We’ll use Alpine.js for managing the state of our search bar (e.g., the search query, results, loading state) in both the frontend and the editor. First, install Alpine.js as a development dependency:

cd wp-content/plugins/my-custom-blocks
npm init -y
npm install alpinejs --save-dev
npm install @wordpress/scripts --save-dev

Next, configure package.json to build our JavaScript assets:

{
  "name": "my-custom-blocks",
  "version": "1.0.0",
  "description": "",
  "main": "build/index.js",
  "scripts": {
    "build": "wp-scripts build",
    "start": "wp-scripts start"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@wordpress/scripts": "^26.10.0",
    "alpinejs": "^3.13.3"
  }
}

Run npm run build to compile the assets. Now, let’s create the JavaScript for our block in src/index.js:

import { registerBlockType } from '@wordpress/blocks';
import { useBlockProps } from '@wordpress/block-editor';
import { useState, useEffect } from '@wordpress/element';
import { TextControl, Spinner } from '@wordpress/components';
import apiFetch from '@wordpress/api-fetch';

// Import Alpine.js
import Alpine from 'alpinejs';

// Register the block
registerBlockType('my-custom-blocks/elasticsearch-search', {
    edit: ({ attributes, setAttributes }) => {
        const blockProps = useBlockProps();
        const { placeholderText } = attributes;

        // State for the editor preview (optional, can be simpler)
        const [editorQuery, setEditorQuery] = useState('');
        const [editorResults, setEditorResults] = useState([]);
        const [isLoading, setIsLoading] = useState(false);

        const handleSearch = async (query) => {
            if (!query) {
                setEditorResults([]);
                return;
            }
            setIsLoading(true);
            try {
                // This endpoint needs to be created in PHP to proxy Elasticsearch requests
                const response = await apiFetch({
                    path: '/my-custom-blocks/v1/search?query=' + encodeURIComponent(query),
                    method: 'GET',
                });
                setEditorResults(response.results || []);
            } catch (error) {
                console.error('Search error:', error);
                setEditorResults([]);
            } finally {
                setIsLoading(false);
            }
        };

        useEffect(() => {
            // Debounce search in editor if needed, for simplicity we'll search on input change
            const handler = setTimeout(() => {
                handleSearch(editorQuery);
            }, 300);
            return () => clearTimeout(handler);
        }, [editorQuery]);

        return (
            <div {...blockProps}>
                <TextControl
                    label="Placeholder Text"
                    value={placeholderText}
                    onChange={(value) => setAttributes({ placeholderText: value })}
                />
                <div x-data="{ query: '', results: [], isLoading: false }" x-init="
                    $watch('query', (value) => {
                        if (value.length > 2) { // Only search if query is longer than 2 chars
                            isLoading = true;
                            fetch('/wp-json/my-custom-blocks/v1/search?query=' + encodeURIComponent(value))
                                .then(response => response.json())
                                .then(data => {
                                    results = data.results || [];
                                    isLoading = false;
                                })
                                .catch(() => {
                                    results = [];
                                    isLoading = false;
                                });
                        } else {
                            results = [];
                            isLoading = false;
                        }
                    })"
                >
                    <input
                        type="search"
                        placeholder={placeholderText}
                        x-model="query"
                        class="block-editor-rich-text__editable wp-block-search__input" // Basic styling
                        style="width: 100%; padding: 8px;"
                    />
                    <div x-show="isLoading"><Spinner /></div>
                    <ul x-show="!isLoading && results.length" style="list-style: none; padding: 0; margin-top: 10px;">
                        <template x-for="result in results" :key="result.id">
                            <li><a :href="result.url" x-text="result.title"></a></li>
                        </template>
                    </ul>
                </div>
            </div>
        );
    },
    save: () => {
        // The save function should return null because the block is rendered server-side.
        // The actual HTML structure will be generated by render.php.
        return null;
    },
});

// Initialize Alpine.js on the frontend
document.addEventListener('alpine:init', () => {
    // Alpine components can be initialized here or directly in the HTML
    // For this block, we'll rely on the x-data directives in the rendered HTML.
});

// Ensure Alpine is initialized globally if not using the 'alpine:init' event
// This is often handled by enqueueing Alpine.js in your theme or plugin.
// If you are enqueueing Alpine.js via wp_enqueue_script, this might not be needed.
// However, for direct usage within the block's JS, it's good practice.
window.Alpine = Alpine;
Alpine.start();

In the edit function, we use WordPress components for the block’s settings (like the placeholder text) and a basic preview. Crucially, we embed Alpine.js directives (x-data, x-init, x-model, x-show, template) directly within the JSX structure. This allows Alpine.js to manage the search input, loading state, and results display dynamically within the Gutenberg editor itself. The save function returns null because the block’s output will be fully generated server-side by PHP.

Server-Side Rendering and API Endpoint

The block.json file specifies a render key pointing to render.php. This file will handle the HTML output for both the frontend and the editor preview when the block is saved.

<?php
/**
 * Render callback for the Elasticsearch Search Bar block.
 *
 * @param array $attributes Block attributes.
 * @return string Rendered block HTML.
 */

// Ensure Alpine.js is enqueued on the frontend.
// You should also enqueue this in your plugin's main PHP file or theme's functions.php.
function my_custom_blocks_enqueue_alpine() {
    wp_enqueue_script( 'alpinejs', plugin_dir_url( __FILE__ ) . '../node_modules/alpinejs/dist/cdn.min.js', array(), '3.13.3', true );
}
// Add this to your main plugin file or functions.php:
// add_action( 'wp_enqueue_scripts', 'my_custom_blocks_enqueue_alpine' );
// add_action( 'admin_enqueue_scripts', 'my_custom_blocks_enqueue_alpine' ); // For editor

// The render.php file itself doesn't enqueue scripts, it assumes they are loaded.

$placeholder_text = isset( $attributes['placeholderText'] ) ? esc_attr( $attributes['placeholderText'] ) : 'Search...';

// The actual HTML structure for the search bar.
// Alpine.js directives are used here for dynamic behavior.
// The 'x-init' directive will trigger the search when the component initializes
// or when the 'query' model changes.
?>
<div
    class="wp-block-my-custom-blocks-elasticsearch-search"
    x-data="{
        query: '',
        results: [],
        isLoading: false,
        placeholder: '',
        init() {
            // Initialize Alpine component
            // If you want to pre-populate search from URL parameters, do it here.
            const urlParams = new URLSearchParams(window.location.search);
            const initialQuery = urlParams.get('s'); // Or a custom param like 'es_query'
            if (initialQuery) {
                this.query = initialQuery;
                this.performSearch(); // Perform search on load if query exists
            }
        },
        performSearch() {
            if (this.query.length < 3) { // Minimum search length
                this.results = [];
                this.isLoading = false;
                return;
            }
            this.isLoading = true;
            // Use wp_remote_get or a custom REST API endpoint to proxy Elasticsearch requests
            // This is crucial for security and CORS.
            fetch('/wp-json/my-custom-blocks/v1/search?query=' + encodeURIComponent(this.query))
                .then(response => response.json())
                .then(data => {
                    this.results = data.results || [];
                    this.isLoading = false;
                    // Optionally update URL history for deep linking/back button
                    if (data.results && data.results.length > 0) {
                        const url = new URL(window.location);
                        url.searchParams.set('es_query', this.query); // Use a custom param
                        window.history.pushState({}, '', url);
                    } else {
                         const url = new URL(window.location);
                         url.searchParams.delete('es_query');
                         window.history.pushState({}, '', url);
                    }
                })
                .catch(() => {
                    this.results = [];
                    this.isLoading = false;
                });
        }
    }"
    x-init="init()"
    @keyup.debounce.300ms="performSearch()" // Debounce search on keyup
>
    <div class="search-container">
        <input
            type="search"
            :placeholder="placeholder"
            x-model="query"
            class="search-input" // Add your custom CSS classes here
            aria-label="Search"
        />
        <div x-show="isLoading" class="search-loading">
            <!-- You can use a CSS spinner or an SVG here -->
            Loading...
        </div>
        <ul x-show="!isLoading && results.length" class="search-results">
            <template x-for="result in results" :key="result.id">
                <li>
                    <a :href="result.url">
                        <span x-text="result.title"></span>
                        <!-- Optionally display price or other info -->
                        <!-- <span x-text="' - $' + result.price"></span> -->
                    </a>
                </li>
            </template>
        </ul>
        <div x-show="!isLoading && !results.length && query.length > 0" class="search-no-results">
            No results found for "
            <span x-text="query"></span>".
        </div>
    </div>
</div>

The render.php file outputs the main container with Alpine.js directives. The x-data object initializes the component’s state: query, results, isLoading, and placeholder. The init() function checks for existing search parameters in the URL (useful for deep linking or when a search term is pre-filled) and triggers an initial search if found. The performSearch() function is the core logic: it debounces the search, makes a fetch request to our custom REST API endpoint, updates the results, and optionally modifies the browser’s history to reflect the search query.

Custom REST API Endpoint for Elasticsearch Proxy

Directly querying Elasticsearch from the frontend (even via fetch in Alpine.js) is a security risk and often blocked by CORS policies. We need a backend endpoint in WordPress to act as a proxy. Add the following to your main plugin file (my-custom-blocks.php):

<?php
// ... (previous plugin code) ...

add_action( 'rest_api_init', function () {
    register_rest_route( 'my-custom-blocks/v1', '/search', array(
        'methods' => 'GET',
        'callback' => 'my_custom_blocks_elasticsearch_search_callback',
        'permission_callback' => '__return_true', // Adjust for security in production
    ) );
} );

function my_custom_blocks_elasticsearch_search_callback( WP_REST_Request $request ) {
    $query = $request->get_param( 'query' );

    if ( empty( $query ) || strlen( $query ) < 3 ) {
        return new WP_Error( 'invalid_query', 'Search query is too short or empty.', array( 'status' => 400 ) );
    }

    // --- Elasticsearch Connection and Query ---
    // IMPORTANT: Use a robust Elasticsearch client library (e.g., official PHP client)
    // For simplicity, this example uses direct HTTP requests.
    // NEVER expose Elasticsearch credentials directly in client-side code or insecurely.
    // Use environment variables or WordPress options for sensitive data.

    $elasticsearch_host = defined('ELASTICSEARCH_HOST') ? ELASTICSEARCH_HOST : 'http://localhost:9200'; // Use WP options or env vars
    $elasticsearch_index = defined('ELASTICSEARCH_INDEX') ? ELASTICSEARCH_INDEX : 'products'; // Use WP options or env vars

    $search_url = trailingslashit( $elasticsearch_host ) . $elasticsearch_index . '/_search';

    $body = json_encode( array(
        'query' => array(
            'multi_match' => array(
                'query' => sanitize_text_field( $query ),
                'fields' => array( 'title^3', 'description', 'categories' ), // Boost title
                'fuzziness' => 'AUTO', // Enable fuzzy matching
            )
        ),
        '_source' => array( 'id', 'title', 'description', 'price', 'categories', 'image_url' ), // Specify fields to return
        'size' => 10, // Limit results
    ) );

    $response = wp_remote_post( $search_url, array(
        'method'    => 'POST',
        'headers'   => array( 'Content-Type' => 'application/json' ),
        'body'      => $body,
        'timeout'   => 15, // Adjust timeout as needed
    ) );

    if ( is_wp_error( $response ) ) {
        return new WP_Error( 'elasticsearch_error', 'Failed to connect to Elasticsearch.', array( 'status' => 500, 'details' => $response->get_error_message() ) );
    }

    $body_response = wp_remote_retrieve_body( $response );
    $data = json_decode( $body_response, true );

    if ( ! $data || isset( $data['error'] ) ) {
        return new WP_Error( 'elasticsearch_query_error', 'Elasticsearch query failed.', array( 'status' => 500, 'details' => $data ) );
    }

    $results = array();
    if ( isset( $data['hits']['hits'] ) ) {
        foreach ( $data['hits']['hits'] as $hit ) {
            $source = $hit['_source'];
            $results[] = array(
                'id' => $source['id'] ?? null,
                'title' => $source['title'] ?? 'N/A',
                'description' => $source['description'] ?? '',
                'price' => $source['price'] ?? null,
                'categories' => $source['categories'] ?? array(),
                'image_url' => $source['image_url'] ?? null,
                // Construct a URL to the product page. This requires knowledge of your permalink structure.
                // Example: Assuming products are a custom post type 'product' with slugs.
                'url' => home_url( '/product/' . sanitize_title( $source['title'] ?? 'product' ) ), // Adjust this URL generation
            );
        }
    }

    return rest_ensure_response( array( 'results' => $results ) );
}

This REST API endpoint:

  • Is registered under /wp-json/my-custom-blocks/v1/search.
  • Accepts a query parameter.
  • Sanitizes the input query.
  • Constructs a multi_match query for Elasticsearch, boosting the title field and enabling fuzzy matching.
  • Specifies the fields to be returned (_source).
  • Limits the number of results (size).
  • Uses wp_remote_post to send the request to your Elasticsearch instance. Ensure ELASTICSEARCH_HOST and ELASTICSEARCH_INDEX are defined securely (e.g., via wp-config.php constants or WordPress options).
  • Processes the Elasticsearch response, extracts relevant data, and constructs product URLs. The URL generation logic (home_url( '/product/' . ... )) needs to be adapted to your specific e-commerce setup (e.g., custom post type, permalink structure).
  • Returns the results as a JSON response.

Styling the Search Bar

Create a CSS file (e.g., src/style.css) for the block’s shared styles and src/editor.scss for editor-specific styles. Enqueue these in your block.json. Here’s a basic example for src/style.css:

.wp-block-my-custom-blocks-elasticsearch-search {
    position: relative;
    width: 100%;
}

.search-input {
    width: 100%;
    padding: 10px 15px;
    border: 1px solid #ccc;
    border-radius: 4px;
    font-size: 1rem;
    box-sizing: border-box; /* Include padding and border in the element's total width and height */
}

.search-loading {
    position: absolute;
    right: 10px;
    top: 50%;
    transform: translateY(-50%);
    color: #888;
}

.search-results {
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    background-color: #fff;
    border: 1px solid #ccc;
    border-top: none;
    border-radius: 0 0 4px 4px;
    list-style: none;
    padding: 0;
    margin: 0;
    z-index: 10;
    box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}

.search-results li {
    padding: 10px 15px;
    border-bottom: 1px solid #eee;
}

.search-results li:last-child {
    border-bottom: none;
}

.search-results li a {
    text-decoration: none;
    color: #333;
    display: block;
    width: 100%;
}

.search-results li a:hover {
    background-color: #f0f0f0;
}

.search-no-results {
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    background-color: #fff;
    border: 1px solid #ccc;
    border-top: none;
    border-radius: 0 0 4px 4px;
    padding: 10px 15px;
    z-index: 10;
    box-shadow: 0 2px 5px rgba(0,0,0,0.1);
    color: #888;
}

Remember to run npm run build after adding or modifying CSS/SCSS files to compile them into the build/ directory.

Deployment and Security Considerations

For production environments:

  • Elasticsearch Credentials: Never hardcode Elasticsearch credentials. Use WordPress options, environment variables, or a secure configuration management system.
  • API Endpoint Permissions: The 'permission_callback' => '__return_true' should be replaced with a proper permission check. For example, you might check if a specific nonce is set or if the user has a particular capability, depending on whether anonymous search is allowed.
  • Rate Limiting: Implement rate limiting on your REST API endpoint to prevent abuse.
  • Input Sanitization: While we’ve used sanitize_text_field and json_encode, ensure all data passed to Elasticsearch is properly sanitized to prevent injection attacks.
  • Elasticsearch Security: Configure Elasticsearch security features (authentication, authorization, TLS/SSL) appropriately.
  • Error Handling: Enhance error handling in both the JavaScript and PHP to provide more user-friendly feedback and detailed logging for administrators.
  • Product URL Generation: The logic for generating product URLs in render.php is critical. Ensure it accurately reflects your site’s permalink structure for custom post types or WooCommerce products.

By combining Gutenberg’s block editor capabilities with the lightweight reactivity of Alpine.js and a secure backend proxy for Elasticsearch, you can build a powerful and performant custom search experience for your e-commerce WordPress site.

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

  • Troubleshooting guide: Resolving memory leak spikes caused by unclosed custom database loops in portfolio project grids
  • How to build custom FSE Block Themes extensions utilizing modern Metadata API (add_post_meta) schemas
  • Optimizing WooCommerce cart response times by lazy loading custom event ticket registers assets
  • WordPress Development Recipe: Efficient binary storage and retrieval in custom tables using PHP 8.x Attributes
  • Step-by-Step Guide to building a custom XML sitemap generator block for Gutenberg using PHP block-render callbacks

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 (47)
  • 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 (140)
  • WordPress Plugin Development (152)
  • WordPress Plugin Development (330)
  • WordPress Theme Development (357)

Recent Posts

  • Troubleshooting guide: Resolving memory leak spikes caused by unclosed custom database loops in portfolio project grids
  • How to build custom FSE Block Themes extensions utilizing modern Metadata API (add_post_meta) schemas
  • Optimizing WooCommerce cart response times by lazy loading custom event ticket registers assets

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