Step-by-Step Guide to building a custom Elasticsearch search bar block for Gutenberg using Svelte standalone templates
Setting Up the WordPress Plugin and Development Environment
We’ll begin by establishing the foundational structure for our custom Gutenberg block. This involves creating a new WordPress plugin and configuring the necessary build tools. For this project, we’ll leverage the official WordPress `@wordpress/scripts` package, which provides a robust build pipeline for JavaScript, CSS, and other assets, including support for modern JavaScript features and React. We’ll also use Svelte for our block’s frontend interface, opting for a standalone template approach to keep the Svelte compilation separate from the main WordPress build process.
First, create a new directory for your plugin within the wp-content/plugins/ directory of your WordPress installation. Let’s name it custom-elasticsearch-search.
Plugin Structure and `plugin.php`
Inside the custom-elasticsearch-search directory, create the main plugin file, custom-elasticsearch-search.php. This file will contain the plugin header and the necessary hooks to register our Gutenberg block.
Configuring the Build Process with `package.json`
Next, we need to set up our build tools. Navigate into your plugin directory in your terminal and initialize npm. Then, install the necessary development dependencies.
cd wp-content/plugins/custom-elasticsearch-search npm init -y npm install @wordpress/scripts @wordpress/blocks @wordpress/components @wordpress/i18n svelte svelte-loader --save-devNow, create a
package.jsonfile in the root of your plugin directory. This file will define our build scripts. We'll configure it to use `@wordpress/scripts` for the main WordPress block compilation and add a separate script for compiling our Svelte component.{ "name": "custom-elasticsearch-search", "version": "1.0.0", "description": "A Gutenberg block for searching Elasticsearch.", "main": "build/index.js", "scripts": { "build": "wp-scripts build", "start": "wp-scripts start", "build:svelte": "svelte-kit build --dir build/svelte-components", "dev:svelte": "svelte-kit dev --port 5173 --hot --watch --dir build/svelte-components" }, "keywords": ["wordpress", "gutenberg", "elasticsearch", "svelte"], "author": "Your Name", "license": "GPL-2.0-or-later", "devDependencies": { "@wordpress/blocks": "^12.0.0", "@wordpress/components": "^26.0.0", "@wordpress/i18n": "^7.0.0", "@wordpress/scripts": "^26.0.0", "svelte": "^4.0.0", "svelte-loader": "^3.0.0", "svelte-preprocess": "^5.0.0", "svelte-kit": "^1.0.0" }, "dependencies": { "axios": "^1.0.0" }, "browserslist": [ "file:./.browserslistrc" ] }Create a
.browserslistrcfile in the root of your plugin directory to specify browser compatibility for autoprefixing.defaults not IE 11 not op_mini allDefining the Block with `block.json`
Create a
block.jsonfile in the root of your plugin directory. This file describes your block to WordPress, including its name, category, attributes, and the scripts and styles to enqueue.{ "$schema": "https://schemas.wp.org/trunk/block.json", "apiVersion": 3, "name": "custom-elasticsearch-search/block", "version": "1.0.0", "title": "Elasticsearch Search Bar", "category": "widgets", "icon": "search", "description": "A custom search bar that queries Elasticsearch.", "keywords": ["elasticsearch", "search", "custom"], "attributes": { "placeholderText": { "type": "string", "default": "Search..." }, "searchEndpoint": { "type": "string", "default": "/wp-json/custom-elasticsearch-search/v1/search" } }, "textdomain": "custom-elasticsearch-search", "editorScript": "file:./build/index.js", "editorStyle": "file:./build/index.css", "style": "file:./build/style-index.css", "viewScript": "file:./build/view.js" }Building the Gutenberg Block Editor Interface (React)
The
editorScriptinblock.jsonpoints tobuild/index.js. This is where the main JavaScript for the block editor will be compiled. We'll use React for the editor-side interface, leveraging WordPress components.Create a
src/index.jsfile. This will be the entry point for the block editor script.import { registerBlockType } from '@wordpress/blocks'; import { __ } from '@wordpress/i18n'; import { InspectorControls, useBlockProps } from '@wordpress/block-editor'; import { PanelBody, TextControl } from '@wordpress/components'; import Edit from './edit'; import save from './save'; registerBlockType( 'custom-elasticsearch-search/block', { edit: Edit, save: save, } );Create
src/edit.jsfor the block's editor interface.import { __ } from '@wordpress/i18n'; import { InspectorControls, useBlockProps } from '@wordpress/block-editor'; import { PanelBody, TextControl } from '@wordpress/components'; const Edit = ( { attributes, setAttributes } ) => { const blockProps = useBlockProps(); const { placeholderText, searchEndpoint } = attributes; const onChangePlaceholder = ( newPlaceholder ) => { setAttributes( { placeholderText: newPlaceholder } ); }; const onChangeSearchEndpoint = ( newEndpoint ) => { setAttributes( { searchEndpoint: newEndpoint } ); }; return ( <> <InspectorControls> <PanelBody title={ __( 'Search Settings', 'custom-elasticsearch-search' ) }> <TextControl label={ __( 'Placeholder Text', 'custom-elasticsearch-search' ) } value={ placeholderText } onChange={ onChangePlaceholder } /> <TextControl label={ __( 'Search API Endpoint', 'custom-elasticsearch-search' ) } value={ searchEndpoint } onChange={ onChangeSearchEndpoint } help={ __( 'The REST API endpoint to query Elasticsearch.', 'custom-elasticsearch-search' ) } /> </PanelBody> </InspectorControls> <div { ...blockProps }> <input type="text" placeholder={ placeholderText } readOnly aria-label={ __( 'Search', 'custom-elasticsearch-search' ) } /> <button disabled>{ __( 'Search', 'custom-elasticsearch-search' ) }</button> <p>{ __( 'This is a preview. The actual search will be powered by Svelte on the frontend.', 'custom-elasticsearch-search' ) }</p> </div> </> ); }; export default Edit;Create
src/save.js. This function determines how the block is saved to the database. Since our Svelte component will handle the dynamic rendering on the frontend, this save function will output static HTML that acts as a container for our Svelte app.import { useBlockProps } from '@wordpress/block-editor'; const save = ( { attributes } ) => { const blockProps = useBlockProps.save(); const { placeholderText, searchEndpoint } = attributes; return ( <div { ...blockProps } data-placeholder-text={ placeholderText } data-search-endpoint={ searchEndpoint } id="custom-elasticsearch-search-svelte-app" ></div> ); }; export default save;Building the Frontend Search Interface with Svelte
Now, let's integrate Svelte for the dynamic frontend search experience. We'll create a separate Svelte application that will be mounted onto the container element generated by our
save.jsfunction. This approach keeps the Svelte build process distinct and allows for more complex Svelte features.Create a new directory
svelte-srcin your plugin's root. Inside, createmain.js, which will be the entry point for your Svelte application.// svelte-src/main.js import App from './App.svelte'; // Find all instances of our block container const searchBlocks = document.querySelectorAll('#custom-elasticsearch-search-svelte-app'); searchBlocks.forEach(blockElement => { // Extract data attributes from the container const placeholderText = blockElement.dataset.placeholderText || 'Search...'; const searchEndpoint = blockElement.dataset.searchEndpoint || '/wp-json/custom-elasticsearch-search/v1/search'; // Mount the Svelte app into the container new App({ target: blockElement, props: { initialPlaceholderText: placeholderText, initialSearchEndpoint: searchEndpoint } }); });Create the main Svelte component,
svelte-src/App.svelte.<script> import { onMount } from 'svelte'; import axios from 'axios'; export let initialPlaceholderText = 'Search...'; export let initialSearchEndpoint = '/wp-json/custom-elasticsearch-search/v1/search'; let searchTerm = ''; let searchResults = []; let isLoading = false; let error = null; // Use a local variable for the endpoint to allow potential updates if needed let searchEndpoint = initialSearchEndpoint; async function performSearch() { if (!searchTerm.trim()) { searchResults = []; error = null; return; } isLoading = true; error = null; searchResults = []; try { const response = await axios.get(searchEndpoint, { params: { q: searchTerm } }); // Assuming Elasticsearch returns results in a 'hits.hits' array searchResults = response.data.hits.hits.map(hit => hit._source); } catch (e) { console.error("Search error:", e); error = 'An error occurred during the search.'; searchResults = []; } finally { isLoading = false; } } // Handle Enter key press for search function handleKeyPress(event) { if (event.key === 'Enter') { performSearch(); } } // Initialize placeholder text from props $: placeholder = initialPlaceholderText; // If you wanted to allow dynamic endpoint changes via attributes, you'd need // to re-evaluate searchEndpoint here or use a reactive statement. // $: searchEndpoint = initialSearchEndpoint; // This would update if initialSearchEndpoint changes </script> <div class="elasticsearch-search-wrapper"> <div class="search-input-group"> <input type="text" bind:value={searchTerm} on:keypress={handleKeyPress} placeholder={placeholder} aria-label={placeholder} disabled={isLoading} /> <button on:click={performSearch} disabled={isLoading || !searchTerm.trim()} > { isLoading ? 'Searching...' : 'Search' } </button> </div> {#if isLoading} <p>Loading results...</p> {:else if error} <p class="error-message">{error}</p> {:else if searchResults.length > 0} <ul class="search-results"> {#each searchResults as result (result.id || JSON.stringify(result))} <li> <h3>{result.title || 'No Title'}</h3> <p>{result.excerpt || result.content || 'No description available.'}</p> {#if result.url} <a href={result.url} target="_blank" rel="noopener noreferrer">Read More</a> {/if} </li> {/each} </ul> {:else if searchTerm.trim()} <p>No results found for "{searchTerm}".</p> {/if} </div> <style> .elasticsearch-search-wrapper { font-family: sans-serif; width: 100%; max-width: 600px; margin: 20px auto; border: 1px solid #ccc; padding: 15px; border-radius: 5px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } .search-input-group { display: flex; gap: 10px; margin-bottom: 15px; } .search-input-group input[type="text"] { flex-grow: 1; padding: 10px; border: 1px solid #ccc; border-radius: 3px; font-size: 1rem; } .search-input-group button { padding: 10px 15px; background-color: #0073aa; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 1rem; transition: background-color 0.2s ease; } .search-input-group button:hover:not(:disabled) { background-color: #005177; } .search-input-group button:disabled { background-color: #ccc; cursor: not-allowed; } .search-results { list-style: none; padding: 0; margin: 0; } .search-results li { margin-bottom: 15px; padding-bottom: 15px; border-bottom: 1px solid #eee; } .search-results li:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; } .search-results h3 { margin: 0 0 5px 0; font-size: 1.2rem; color: #333; } .search-results p { margin: 0 0 10px 0; color: #555; line-height: 1.5; } .search-results a { color: #0073aa; text-decoration: none; font-weight: bold; } .search-results a:hover { text-decoration: underline; } .error-message { color: red; font-weight: bold; } </style>Configuring SvelteKit for Standalone Compilation
To use SvelteKit for building our standalone component, we need a minimal SvelteKit configuration. Create a
svelte.config.jsfile in your plugin's root directory.// svelte.config.js import adapter from '@sveltejs/adapter-static'; // Or use adapter-auto if deploying to a server with Node.js /** @type {import('@sveltejs/kit').Config} */ const config = { kit: { adapter: adapter({ // Options for adapter-static pages: 'build/svelte-components', // Output directory for static assets assets: 'build/svelte-components', // Assets directory fallback: null // No fallback page needed for this use case }), paths: { base: '', // Relative paths for assets }, prerender: { entries: [] // No prerendering needed for this dynamic component } }, preprocess: { // Add any Svelte preprocessors here if needed (e.g., for TypeScript, SCSS) // Example: import sveltePreprocess from 'svelte-preprocess'; // script: async () => sveltePreprocess(), // style: async () => sveltePreprocess(), } }; export default config;We also need to tell SvelteKit where to find our main Svelte entry point. Create a
src/routes/+page.sveltefile. This file won't be directly rendered by SvelteKit in this setup, but it's required for SvelteKit's build process.<!-- src/routes/+page.svelte --> <script> // This file is a placeholder for SvelteKit's build process. // The actual application is mounted in svelte-src/main.js. </script> <h1>Elasticsearch Search Block</h1> <p>This is a placeholder page. The search component is loaded dynamically.</p>And a minimal
src/routes/+layout.svelte.<!-- src/routes/+layout.svelte --> <slot />Building the Assets
With the structure and code in place, we can now build our assets. Run the following commands in your plugin's root directory:
npm run build npm run build:svelteThis will generate the necessary JavaScript and CSS files in the
build/directory for the WordPress editor, and the Svelte components inbuild/svelte-components/. The `wp-scripts build` command compilessrc/index.jsintobuild/index.jsandbuild/index.css. The `build:svelte` command, using our SvelteKit configuration, compiles the Svelte application intobuild/svelte-components/.Enqueuing the Svelte View Script
Our
block.jsonspecifies aviewScript:file:./build/view.js. This script will be enqueued on the frontend when our block is present. We need to create this file and ensure it loads our compiled Svelte application.Create a
src/view.jsfile. This script will be responsible for finding our block's container and mounting the Svelte application.import('./svelte-components/index.js'); // This path assumes adapter-static outputting to build/svelte-componentsNow, we need to tell `@wordpress/scripts` to compile this
src/view.jsintobuild/view.js. We can achieve this by adding a custom script to ourpackage.jsonand running it after the main build.Modify your
package.jsonscripts:{ // ... other scripts "scripts": { "build": "wp-scripts build", "start": "wp-scripts start", "build:svelte": "svelte-kit build --dir build/svelte-components", "dev:svelte": "svelte-kit dev --port 5173 --hot --watch --dir build/svelte-components", "build:view": "wp-scripts build --input=src/view.js --output-path=build", "build:all": "npm run build && npm run build:svelte && npm run build:view" } // ... other configurations }Now, run the combined build command:
npm run build:allThis ensures that
src/view.jsis compiled and placed correctly in thebuild/directory, and that the Svelte application is also built and placed inbuild/svelte-components/. The `import('./svelte-components/index.js');` insrc/view.jswill correctly resolve to the compiled Svelte output.Creating the WordPress REST API Endpoint
To make the search functional, we need a WordPress REST API endpoint that will proxy requests to your Elasticsearch instance. Create a new file,
includes/rest-api.php, within your plugin directory.<?php /** * Custom Elasticsearch Search REST API Endpoint. */ if ( ! defined( 'ABSPATH' ) ) { exit; // Exit if accessed directly. } // Define your Elasticsearch connection details. // In a production environment, these should be stored securely (e.g., environment variables or wp-config.php). define( 'ELASTICSEARCH_HOST', 'http://localhost:9200' ); // Replace with your Elasticsearch host define( 'ELASTICSEARCH_INDEX', 'your_index_name' ); // Replace with your Elasticsearch index name // define( 'ELASTICSEARCH_USERNAME', 'elastic' ); // Uncomment and set if authentication is required // define( 'ELASTICSEARCH_PASSWORD', 'changeme' ); // Uncomment and set if authentication is required /** * Registers the REST API route for Elasticsearch search. */ function custom_elasticsearch_register_search_route() { register_rest_route( 'custom-elasticsearch-search/v1', '/search', array( 'methods' => WP_REST_Server::READABLE, 'callback' => 'custom_elasticsearch_handle_search', 'permission_callback' => '__return_true', // Adjust permissions as needed for security 'args' => array( 'q' => array( 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'description' => 'The search query.', ), ), ) ); } add_action( 'rest_api_init', 'custom_elasticsearch_register_search_route' ); /** * Handles the Elasticsearch search request. * * @param WP_REST_Request $request Full data about the request. * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure. */ function custom_elasticsearch_handle_search( WP_REST_Request $request ) { $query = $request->get_param( 'q' ); if ( empty( $query ) ) { return new WP_Error( 'missing_param', 'Search query parameter "q" is required.', array( 'status' => 400 ) ); } // Basic Elasticsearch query structure. Customize this based on your needs. // This example searches across a 'content' field. $search_body = array( 'query' => array( 'multi_match' => array( 'query' => $query, 'fields' => array( 'title^3', 'content', 'excerpt' ), // Boost title field 'fuzziness' => 'AUTO', // Enable fuzzy matching ), ), 'size' => 10, // Number of results to return ); $args = array( 'method' => 'GET', 'body' => json_encode( $search_body ), 'headers' => array( 'Content-Type' => 'application/json', ), ); // Add authentication if defined if ( defined( 'ELASTICSEARCH_USERNAME' ) && defined( 'ELASTICSEARCH_PASSWORD' ) ) { $args['headers']['Authorization'] = 'Basic ' . base64_encode( ELASTICSEARCH_USERNAME . ':' . ELASTICSEARCH_PASSWORD ); } $es_url = ELASTICSEARCH_HOST . '/' . ELASTICSEARCH_INDEX . '/_search'; $response = wp_remote_request( $es_url, $args ); if ( is_wp_error( $response ) ) { return new WP_Error( 'elasticsearch_error', 'Error communicating with Elasticsearch.', array( 'status' => 500, 'details' => $response->get_error_message() ) ); } $response_code = wp_remote_retrieve_response_code( $response ); $response_body = wp_remote_retrieve_body( $response ); $data = json_decode( $response_body, true ); if ( $response_code >= 400 ) { return new WP_Error( 'elasticsearch_api_error', 'Elasticsearch API returned an error.', array( 'status' => $response_code, 'details' => $data ) ); } // Return only the relevant parts of the Elasticsearch response // Adjust this based on what your Svelte component expects. // For example, you might want to map fields to a consistent structure. $formatted_results = array( 'hits' => array( 'hits' => $data['hits']['hits'] ?? array(), ), 'took' => $data['took'] ?? 0, 'timed_out' => $data['timed_out'] ?? false, ); return rest_ensure_response( $formatted_results ); }Make sure to include this file in your main plugin file:
// In custom-elasticsearch-search.php require_once plugin_dir_path( __FILE__ ) . 'includes/rest-api.php';Final Steps and Testing
Activate your "Custom Elasticsearch Search Block" plugin in the WordPress admin area. Then, add the "Elasticsearch Search Bar" block to a post or page. Configure the placeholder text and the search API endpoint in the block inspector. Save the post and view it on the frontend. As you type in the search bar and press Enter, the Svelte component should query your configured Elasticsearch endpoint via the WordPress REST API and display the results.
Important Considerations:
- Security: The provided REST API endpoint has
'permission_callback' => '__return_true'for simplicity. In a production environment, you MUST implement proper authentication and authorization to prevent unauthorized access to your Elasticsearch instance. Consider using WordPress nonces or custom API keys. - Elasticsearch Indexing: Ensure your WordPress content is properly indexed in Elasticsearch. You might need a separate plugin or process to push content to Elasticsearch. The example REST API assumes fields like
title,content, andexcerptare available in your Elasticsearch documents. - Error Handling: The Svelte component includes basic error handling, but you may want to enhance this for a better user experience.
- Styling: The Svelte component includes inline styles. For better maintainability, consider extracting these into a separate CSS file and importing it into your Svelte component, or enqueueing a dedicated stylesheet via WordPress.
- SvelteKit Adapter: For a purely static frontend deployment (e.g., serving HTML, CSS, JS directly from WordPress without a Node.js server),
@sveltejs/adapter-staticis appropriate. If your WordPress site is hosted on a server with Node.js capabilities, you could explore@sveltejs/adapter-autoor other server-side adapters.