Subtle diagonal pinstripe pattern in light grey and cream, giving an impression of printed editorial paper

Build an AJAX Search Form in WordPress

A custom search form can make a WordPress site feel considerably faster when visitors need to find products, documentation, posts, or support articles. Instead of sending users to a separate results page after every keystroke, the browser can request matching content in the background and update a results panel without a full page reload.

This approach suits Australian websites with large catalogues, local service directories, university resources, and WooCommerce stores serving customers from Sydney to Perth. The implementation needs three well-connected parts: an accessible form, a JavaScript request, and a secure WordPress callback that returns useful markup or structured data.

Plan the search experience

Start by deciding what the search should find. A standard WordPress search may cover posts and pages, while a custom search can target products, a resource custom post type, selected taxonomies, or specific metadata. Defining that scope early prevents the callback from querying the entire database for every character entered.

A live search should also have a sensible trigger. Searching after two or three characters is usually more useful than querying for a single letter, and a short debounce delay prevents a request from being sent on every keypress. For a small site in Brisbane, a 250-millisecond delay may feel instant; a larger WooCommerce catalogue hosted in another region may benefit from a slightly longer delay.

The HTML should remain usable when JavaScript is unavailable. Give the form a normal action pointing to the search results page, then enhance it with AJAX. Include a label, a search input, a submit button, and a results container with an appropriate live-region setting.

<form class="js-live-search" action="/" method="get">
    <label for="site-search">Search the site</label>
    <input id="site-search" name="s" type="search"
           autocomplete="off" minlength="3">
    <button type="submit">Search</button>
    <div class="search-results" aria-live="polite"></div>
</form>

Register the AJAX endpoint

WordPress AJAX requests are routed through admin-ajax.php. Logged-in and logged-out visitors use different hooks, so register both when public visitors need access. The action name must match the value sent by JavaScript.

Enqueue the script properly rather than printing JavaScript directly in a theme template. Passing the AJAX URL and nonce with wp_localize_script() or wp_add_inline_script() keeps PHP-generated values available to the browser without hard-coding environment-specific paths.

add_action( 'wp_enqueue_scripts', 'site_enqueue_live_search' );

function site_enqueue_live_search() {
    wp_enqueue_script(
        'site-live-search',
        get_template_directory_uri() . '/assets/js/live-search.js',
        array(),
        '1.0.0',
        true
    );

    wp_localize_script(
        'site-live-search',
        'siteSearch',
        array(
            'ajaxUrl' => admin_url( 'admin-ajax.php' ),
            'nonce'   => wp_create_nonce( 'site_live_search' ),
        )
    );
}

add_action( 'wp_ajax_site_live_search', 'site_live_search_callback' );
add_action( 'wp_ajax_nopriv_site_live_search', 'site_live_search_callback' );

A nonce helps verify that the request was generated by your site, although it is not an access-control mechanism. Keep the callback public only when public search is intended, and consider rate limiting or caching if the endpoint becomes a target for automated requests.

Query and return useful results

The callback should validate the incoming search term, limit the result count, and specify the post types explicitly. sanitize_text_field() is appropriate for the incoming phrase, while esc_html() and esc_url() protect values when they are printed into the response.

function site_live_search_callback() {
    check_ajax_referer( 'site_live_search', 'nonce' );

    $term = isset( $_POST['term'] )
        ? sanitize_text_field( wp_unslash( $_POST['term'] ) )
        : '';

    if ( mb_strlen( $term ) < 3 ) {
        wp_send_json_success(
            array( 'html' => '<p>Enter at least three characters.</p>' )
        );
    }

    $query = new WP_Query(
        array(
            'post_type'           => array( 'post', 'page' ),
            'post_status'         => 'publish',
            's'                   => $term,
            'posts_per_page'      => 8,
            'ignore_sticky_posts' => true,
            'no_found_rows'       => true,
        )
    );

    ob_start();

    if ( $query->have_posts() ) {
        echo '<ul class="search-results__list">';

        while ( $query->have_posts() ) {
            $query->the_post();
            printf(
                '<li><a href="%1$s">%2$s</a></li>',
                esc_url( get_permalink() ),
                esc_html( get_the_title() )
            );
        }

        echo '</ul>';
    } else {
        echo '<p>No matching results were found.</p>';
    }

    wp_reset_postdata();

    wp_send_json_success(
        array( 'html' => ob_get_clean() )
    );
}

Returning a small HTML fragment is straightforward when the result layout is simple. For a richer interface, return an array of result objects as JSON and build the elements in JavaScript. That separation is useful when the same search data will later power a mobile interface or a block-based component.

The query can be adapted for WooCommerce with post_type set to product, or narrowed with tax_query and meta_query. Avoid an unrestricted metadata search on a busy shop: LIKE queries against unindexed post meta can become expensive, especially during a seasonal campaign such as Boxing Day sales.

Approach Strengths Trade-offs Suitable use
admin-ajax.php Familiar WordPress hooks, works in older themes and plugins, easy to add to an existing site Requests pass through a general-purpose endpoint and usually require custom response handling Theme-level live search and small custom plugins
WordPress REST API Clean JSON responses, clear routes, easier integration with modern front ends Requires registering a route and planning permission callbacks Headless sites, blocks, Vue, React, or mobile clients
Normal search submission Works without JavaScript, excellent fallback, simple caching behaviour Reloads the page and gives less immediate feedback Accessible baseline and low-complexity websites
Client-side filtering Very quick after data is loaded, no repeated server requests Requires downloading and maintaining the complete dataset Small, stable lists such as office locations
Search plugin integration Relevance ranking, stemming, filters, and indexing may already be available Adds configuration, dependencies, and possible styling conflicts Large content libraries and WooCommerce catalogues

Connect the browser to WordPress

The JavaScript should listen for input, wait briefly, cancel an older timer, and send the latest term. URLSearchParams keeps the request body readable, while fetch() avoids the need for jQuery on a modern WordPress installation.

document.addEventListener('DOMContentLoaded', () => {
    const form = document.querySelector('.js-live-search');

    if (!form || typeof siteSearch === 'undefined') {
        return;
    }

    const input = form.querySelector('input[name="s"]');
    const output = form.querySelector('.search-results');
    let timer;

    const search = () => {
        const term = input.value.trim();

        if (term.length < 3) {
            output.innerHTML = '<p>Type at least three characters.</p>';
            return;
        }

        output.innerHTML = '<p>Searching…</p>';

        const body = new URLSearchParams({
            action: 'site_live_search',
            nonce: siteSearch.nonce,
            term
        });

        fetch(siteSearch.ajaxUrl, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
                'Accept': 'application/json'
            },
            body
        })
            .then(response => response.json())
            .then(data => {
                if (data.success && data.data.html) {
                    output.innerHTML = data.data.html;
                } else {
                    output.innerHTML = '<p>Search could not be completed.</p>';
                }
            })
            .catch(() => {
                output.innerHTML = '<p>Please try again shortly.</p>';
            });
    };

    input.addEventListener('input', () => {
        window.clearTimeout(timer);
        timer = window.setTimeout(search, 250);
    });

    form.addEventListener('submit', event => {
        event.preventDefault();
        window.clearTimeout(timer);
        search();
    });
});

A production implementation should also guard against race conditions. If a visitor types rapidly, an earlier, slower response could arrive after a newer response and overwrite it. An AbortController, request counter, or response term check can ensure that only the latest request updates the result panel.

Do not assume the returned HTML is safe merely because it came from your own server. Keep output escaped in PHP, avoid inserting untrusted strings into HTML, and use text nodes when constructing results in JavaScript. The search status should be announced through aria-live, and keyboard users should be able to move from the input to result links without unexpected focus changes.

Improve relevance and performance

WordPress's default search is often adequate for a small blog, yet it may rank results poorly when titles, excerpts, and product attributes all matter. A custom search can use pre_get_posts for the standard results page or a search extension that builds a separate index. For a documentation site, prioritising headings and exact phrases generally produces better outcomes than simply returning the newest posts.

Keep each live response small. Eight results with a title, URL, content type, and short excerpt are usually enough for suggestions. Add a “View all results” link that submits the original term to the full search page. This gives people in Melbourne looking for a specific policy or service a clear path when the suggestion list is incomplete.

Caching can reduce repeated database work. A transient keyed by a normalised search term may be appropriate for public content with infrequent changes, though cached output must be invalidated or given a short expiry when posts change. Object caching through Redis or the hosting platform is preferable on a busy site. Monitor slow queries rather than guessing, particularly if the search includes custom fields.

Testing should cover empty terms, punctuation, accented characters, private posts, draft content, no-result responses, and network failures. Browser automation is useful for checking that the form works at desktop and mobile widths; a tool such as browser test workflows can help verify that typing, loading states, and result links behave consistently.

Handle security and accessibility

The callback must never trust a post ID, taxonomy value, or sort parameter supplied by the browser. Whitelist allowed post types and ordering values, validate pagination integers with absint(), and reject requests that exceed sensible limits. A nonce check, capability check where needed, and published-status filter form the basic security boundary.

Public AJAX endpoints can be abused even when the code is secure. Add a lightweight per-IP throttle at the web server, CDN, or application layer if search traffic becomes excessive. Cloudflare or a managed Australian host may provide rate-limiting controls, while a WAF can block obvious automated abuse before PHP runs.

Accessibility needs to be designed into the interaction. The visible label should remain available, loading and error messages should be announced, and colour should not be the sole way of indicating state. Keep the normal form submission available, because some visitors block scripts, use assistive technology, or experience a temporary JavaScript failure.

Run the completed feature through keyboard navigation and screen-reader checks, then test on common connections in Australia. A customer on mobile data in regional Tasmania should receive a useful loading state rather than an apparently frozen form, while users on the NBN in Adelaide should see results without layout shifts. For troubleshooting WordPress hooks, AJAX errors, and PHP notices, WordPress Q&A guidance can provide useful reference points.

Package the feature for maintenance

Place the endpoint and enqueue logic in a small site plugin when the search belongs to the website rather than the visual theme. A theme-based implementation will disappear when the theme changes, whereas a plugin keeps the server-side contract available during a redesign. Store the JavaScript in a versioned asset file and update the version when deploying changes so browsers do not retain stale code.

Use consistent naming for the action, nonce, JavaScript object, CSS classes, and result fields. Document whether the endpoint returns HTML or JSON, which post types it searches, and the minimum term length. Future developers should be able to add WooCommerce products or a help_article post type without reverse-engineering an anonymous callback.

Finally, measure whether the feature improves search behaviour. Track request errors, empty-result rates, popular terms, and clicks on returned results without storing unnecessary personal information. If many Australians search for suburb names, product model numbers, or local service terms and receive no matches, the next improvement may be better content, synonym handling, or a dedicated search index rather than more JavaScript.