How to create instant search with REST fetch debounce in WordPress

Contents

Introduction

This tutorial shows how to implement an instant search (typeahead) for WordPress using the REST API, the native fetch() browser API, and a debounce function to avoid excessive requests. It covers two approaches:

  • Using the built-in WordPress REST endpoint (wp/v2/posts?search=…). Quick, works for many sites.
  • Creating a custom REST endpoint for advanced control (fields, relations, caching, permissions, taxonomy filters, highlighting, etc.).

You will learn the backend PHP required for secure and efficient responses, the frontend JavaScript to call the REST API with fetch and AbortController, a robust debounce implementation, rendering, keyboard accessibility, basic caching and performance tips, and how to enqueue and localize the script in WordPress.

Prerequisites

  • WordPress 4.7 (REST API included)
  • Basic skills editing theme/plugin PHP files and adding JS/CSS
  • Modern browser (fetch, AbortController). Polyfills for older browsers optional.

High-level architecture

  1. User types in an input field.
  2. A debounced JS handler sends requests to the REST API using fetch().
  3. Server returns JSON results (posts or custom response).
  4. Client renders results in a live suggestions list with proper ARIA roles and keyboard handling.

Approach A — Use the built-in posts search (fastest)

WordPress exposes a search parameter on the core posts endpoint: /wp-json/wp/v2/posts?search=term. You can use that directly for simple cases.

Frontend HTML (snippet)

Place an input and a container for results where you want the widget to appear.

ltlabel for=instant-search-input class=screen-reader-textgtSearchlt/labelgt
ltinput id=instant-search-input type=search autocomplete=off placeholder=Search posts... aria-label=Search posts /gt
ltdiv id=instant-search-results role=listbox aria-live=polite aria-expanded=falsegtlt/divgt

Enqueue script and localize REST URL nonce

Add this to your themes functions.php or a plugin to enqueue the JS and pass REST URL and nonce. Using wp_create_nonce(wp_rest) for cookie authentication is optional if you need non-public endpoints. For public reading, the core endpoint is fine without a nonce.

// functions.php (or plugin)
function my_enqueue_instant_search() {
    wp_enqueue_script(
        my-instant-search,
        get_stylesheet_directory_uri() . /js/instant-search.js,
        array(),
        1.0,
        true
    )

    wp_localize_script( my-instant-search, MyInstantSearchSettings, array(
        rest_url => esc_url_raw( rest_url( /wp/v2/posts ) ),
        nonce    => wp_create_nonce( wp_rest ), // optional for public GETs
    ) )
}
add_action( wp_enqueue_scripts, my_enqueue_instant_search )

JavaScript: fetch debounce AbortController render

This JS does the heavy lifting: it debounces the input, cancels in-flight requests when a new query begins, caches results in-memory to reduce network use, and renders a minimal accessible list with keyboard navigation.

// instant-search.js
(function () {
  const input = document.getElementById(instant-search-input)
  const resultsContainer = document.getElementById(instant-search-results)
  const settings = window.MyInstantSearchSettings  {}
  const baseURL = settings.rest_url  window.location.origin   /wp-json/wp/v2/posts
  const nonce = settings.nonce  
  const minChars = 2
  const debounceWait = 300 // ms

  let timer = null
  let activeController = null
  const cache = new Map() // simple in-memory cache
  let focusedIndex = -1
  let currentResults = []

  function debounce(fn, wait) {
    return function(...args) {
      clearTimeout(timer)
      timer = setTimeout(() => fn.apply(this, args), wait)
    }
  }

  function clearResults() {
    resultsContainer.innerHTML = 
    resultsContainer.setAttribute(aria-expanded, false)
    currentResults = []
    focusedIndex = -1
  }

  function renderResults(items) {
    resultsContainer.innerHTML = 
    if (!items  items.length === 0) {
      resultsContainer.setAttribute(aria-expanded, false)
      resultsContainer.innerHTML = ltdiv role=quotoptionquotgtNo resultslt/divgt
      return
    }
    const fragment = document.createDocumentFragment()
    items.forEach((post, i) => {
      const el = document.createElement(div)
      el.className = instant-search-item
      el.setAttribute(role, option)
      el.setAttribute(data-index, i)
      el.tabIndex = -1
      el.innerHTML = ltstronggt   escapeHtml(post.title.rendered)   lt/stronggt  
                     (post.excerpt ? ltdiv class=quotexcerptquotgt   stripTags(post.excerpt.rendered)   lt/divgt : )
      el.addEventListener(click, () => {
        window.location.href = post.link
      })
      fragment.appendChild(el)
    })
    resultsContainer.appendChild(fragment)
    resultsContainer.setAttribute(aria-expanded, true)
    currentResults = items
    focusedIndex = -1
  }

  function escapeHtml(str) {
    return String(str).replace(/[ampltgtquot]/g, function (s) {
      return {:amp,<:lt,>:gt,:quot,:#39}[s]
    })
  }

  function stripTags(html) {
    return html ? html.replace(/lt[^gt] gt/g, ) : 
  }

  function doSearch(term) {
    if (!term  term.length lt minChars) {
      clearResults()
      return
    }

    if (cache.has(term)) {
      renderResults(cache.get(term))
      return
    }

    // cancel previous request
    if (activeController) {
      activeController.abort()
    }
    activeController = new AbortController()
    const signal = activeController.signal

    const url = new URL(baseURL)
    url.searchParams.set(search, term)
    url.searchParams.set(per_page, 10) // limit results

    const headers = {}
    if (nonce) {
      headers[X-WP-Nonce] = nonce
    }

    // show loading indicator
    resultsContainer.innerHTML = ltdiv class=quotloadingquotgtLoading...lt/divgt
    fetch(url.toString(), { method: GET, credentials: same-origin, headers, signal })
      .then(resp =gt {
        if (!resp.ok) throw new Error(Network response was not ok    resp.status)
        return resp.json()
      })
      .then(data =gt {
        cache.set(term, data)
        renderResults(data)
      })
      .catch(err =gt {
        if (err.name === AbortError) return // aborted
        console.error(Search fetch error:, err)
        clearResults()
      })
      .finally(() =gt {
        activeController = null
      })
  }

  const debouncedSearch = debounce(function (e) {
    const q = e.target.value.trim()
    doSearch(q)
  }, debounceWait)

  input.addEventListener(input, debouncedSearch)

  // Keyboard navigation (ArrowUp, ArrowDown, Enter, Escape)
  input.addEventListener(keydown, function (e) {
    const items = resultsContainer.querySelectorAll(.instant-search-item)
    if (!items.length) return

    if (e.key === ArrowDown) {
      e.preventDefault()
      focusedIndex = Math.min(focusedIndex   1, items.length - 1)
      items[focusedIndex].focus()
    } else if (e.key === ArrowUp) {
      e.preventDefault()
      focusedIndex = Math.max(focusedIndex - 1, 0)
      items[focusedIndex].focus()
    } else if (e.key === Escape) {
      clearResults()
      input.blur()
    }
  })

  // Allow keyboard selection
  resultsContainer.addEventListener(keydown, function (e) {
    if (e.key === Enter) {
      const el = e.target
      if (el  el.classList.contains(instant-search-item)) {
        const post = currentResults[parseInt(el.getAttribute(data-index), 10)]
        if (post  post.link) window.location.href = post.link
      }
    } else if (e.key === ArrowDown  e.key === ArrowUp) {
      // let input handler manage focus changes
    } else if (e.key === Escape) {
      clearResults()
      input.focus()
    }
  })

})()

Notes about Approach A

  • Pros: No PHP required beyond enqueueing script and localizing rest_url. Fast to implement.
  • Cons: The core endpoint returns the standard schema — additional fields or relationships may require a custom endpoint.
  • Be careful with per_page: default WP core may restrict per_page maximum (often 100). Use small per_page for search suggestions.

Approach B — Create a custom REST endpoint (recommended for advanced control)

A custom endpoint gives full control over response shape, additional fields (custom fields, taxonomies), caching, ordering, and permissions. Below is a robust example.

Register a custom REST route

Add the following to functions.php or a plugin. This registers /wp-json/my-search/v1/suggest and allows public GET requests. The callback sanitizes input, uses WP_Query, and returns minimal fields to the client.

// functions.php or plugin
add_action( rest_api_init, function () {
    register_rest_route( my-search/v1, /suggest, array(
        methods  => GET,
        callback => my_search_suggest_callback,
        args     => array(
            q => array(
                required          => true,
                sanitize_callback => sanitize_text_field,
            ),
            post_type => array(
                required          => false,
                sanitize_callback => sanitize_text_field,
                default           => post,
            ),
            per_page => array(
                required          => false,
                sanitize_callback => absint,
                default           => 10,
            ),
        ),
        permission_callback => __return_true, // public read ok change for private needs
    ) )
} )

function my_search_suggest_callback( WP_REST_Request request ) {
    q = request->get_param( q )
    post_type = request->get_param( post_type )
    per_page = min( 50, (int) request->get_param( per_page ) ) // limit max

    if ( empty( q )  strlen( q ) lt 2 ) {
        return rest_ensure_response( array() )
    }

    cache_key = my_search_suggest_ . md5( q .  . post_type .  . per_page )
    cached = get_transient( cache_key )
    if ( false !== cached ) {
        return rest_ensure_response( cached )
    }

    args = array(
        s => q,
        post_type => post_type,
        posts_per_page => per_page,
        post_status => publish,
        no_found_rows => true, // improve performance
        update_post_meta_cache => false,
        update_post_term_cache => false,
    )

    query = new WP_Query( args )
    results = array()

    foreach ( query->posts as post ) {
        results[] = array(
            id    => post->ID,
            title => get_the_title( post ),
            link  => get_permalink( post ),
            // example: return a trimmed excerpt, featured image URL, custom field
            excerpt => wp_trim_words( strip_shortcodes( strip_tags( post->post_excerpt ? post->post_excerpt : post->post_content ) ), 20 ),
            featured_image => get_the_post_thumbnail_url( post, thumbnail ),
        )
    }

    set_transient( cache_key, results, HOUR_IN_SECONDS ) // cache for 1 hour choose appropriate TTL

    return rest_ensure_response( results )
}

Why caching on the server?

  • Search queries can be expensive caching transient results reduces DB load for repeated queries.
  • Use short TTLs (minutes to an hour) depending on content update frequency.

Secure and sanitize inputs

  • Use sanitize_text_field or more strict sanitizers for inputs.
  • Limit numeric inputs (per_page) and enforce maxima to avoid heavy queries.
  • Permission callbacks: for public suggestions __return_true is ok for private results, verify current_user_can.

Client JS for custom endpoint

Change baseURL to your custom route, and adapt parsing to returned fields.

// instant-search-custom.js (similar to earlier file but using custom endpoint)
(function () {
  const input = document.getElementById(instant-search-input)
  const resultsContainer = document.getElementById(instant-search-results)
  const settings = window.MyInstantSearchSettings  {}
  const baseURL = settings.custom_url  (settings.rest_url  settings.rest_route)  window.location.origin   /wp-json/my-search/v1/suggest
  const nonce = settings.nonce  
  const minChars = 2
  const debounceWait = 250
  let timer = null
  let activeController = null
  const cache = new Map()

  function debounce(fn, wait) {
    return function(...args) {
      clearTimeout(timer)
      timer = setTimeout(() =gt fn.apply(this, args), wait)
    }
  }

  function doSearch(term) {
    if (!term  term.length lt minChars) {
      resultsContainer.innerHTML = 
      return
    }
    if (cache.has(term)) {
      renderResults(cache.get(term))
      return
    }

    if (activeController) activeController.abort()
    activeController = new AbortController()
    const signal = activeController.signal

    const url = new URL(baseURL)
    url.searchParams.set(q, term)
    url.searchParams.set(per_page, 8)

    const headers = {}
    if (nonce) headers[X-WP-Nonce] = nonce

    resultsContainer.innerHTML = ltdiv class=quotloadingquotgtLoading...lt/divgt
    fetch(url.toString(), { credentials: same-origin, headers, signal })
      .then(resp =gt {
        if (!resp.ok) throw new Error(Network error:    resp.status)
        return resp.json()
      })
      .then(data =gt {
        cache.set(term, data)
        renderResults(data)
      })
      .catch(err =gt {
        if (err.name === AbortError) return
        console.error(err)
        resultsContainer.innerHTML = 
      })
      .finally(() =gt {
        activeController = null
      })
  }

  function renderResults(results) {
    resultsContainer.innerHTML = 
    if (!results  results.length === 0) {
      resultsContainer.innerHTML = ltdiv role=quotoptionquotgtNo resultslt/divgt
      return
    }
    const fragment = document.createDocumentFragment()
    results.forEach((r, i) =gt {
      const item = document.createElement(div)
      item.className = instant-search-item
      item.setAttribute(role, option)
      item.setAttribute(data-index, i)
      item.tabIndex = -1
      item.innerHTML = (r.featured_image ? ltimg src=quot   r.featured_image   quot alt=quotquot class=quotthumbquotgt : )  
                       ltdiv class=quotmetaquotgtltstronggt   escapeHtml(r.title)   lt/stronggtlt/divgt  
                       (r.excerpt ? ltdiv class=quotexcerptquotgt   escapeHtml(r.excerpt)   lt/divgt : )
      item.addEventListener(click, () =gt (window.location.href = r.link))
      fragment.appendChild(item)
    })
    resultsContainer.appendChild(fragment)
    resultsContainer.setAttribute(aria-expanded, true)
  }

  function escapeHtml(s) {
    return String(s  ).replace(/[ampltgtquot]/g, function (c) {
      return {:amp,<:lt,>:gt,:quot,:#39}[c]
    })
  }

  const debounced = debounce(function (e) {
    doSearch(e.target.value.trim())
  }, debounceWait)

  input.addEventListener(input, debounced)

})()

Accessibility and UX details

  • Use role=listbox on the container and role=option on items for assistive tech.
  • Set aria-live=polite to announce new results, and aria-expanded to indicate visibility.
  • Provide keyboard navigation: ArrowUp/ArrowDown to move, Enter to select, Escape to close.
  • Ensure focus management: items should be focusable (tabIndex=-1) and focus moved programmatically.
  • Provide visible focus styles for keyboard users.

Performance, throttling and debounce tuning

  • Debounce vs throttle: use debounce for search inputs so the query fires after typing pauses. Throttle can be used for scroll-driven queries.
  • Debounce interval: 200–400ms is common. Lower values increase responsiveness but increase network traffic.
  • Use AbortController to cancel stale requests to avoid race conditions and wasted work.
  • Server-side: set no_found_rows = true in WP_Query to skip pagination overhead.
  • Cache frequent queries server-side (transients) and optionally client-side (in-memory map).

Security considerations

  • Sanitize all input on the server (sanitize_text_field, absint, etc.).
  • Limit per_page and restrict complex queries to authenticated users if needed.
  • Use permission_callback in register_rest_route to gate sensitive data.
  • Do not expose raw custom fields or private data unless authorized.

Styling (example CSS)

Add styles for the input, results container, items, focus, and loading state.

/ instant-search.css /
#instant-search-results {
  border: 1px solid #ddd
  max-height: 360px
  overflow-y: auto
  background: #fff
  position: absolute
  z-index: 9999
  width: 100%
  box-shadow: 0 4px 8px rgba(0,0,0,0.05)
}
.instant-search-item {
  padding: 10px
  border-bottom: 1px solid #f1f1f1
  cursor: pointer
  display: flex
  align-items: center
}
.instant-search-item .thumb {
  width: 48px
  height: 48px
  object-fit: cover
  margin-right: 10px
  border-radius: 4px
}
.instant-search-item:focus,
.instant-search-item:hover {
  background: #f7f7f7
  outline: none
}
.instant-search-item .excerpt {
  color: #666
  font-size: 13px
  margin-top: 6px
}
.loading {
  padding: 10px
  color: #666
}

Advanced features (ideas and code hints)

  • Highlight matched terms on the client: wrap matches in ltmarkgt by using regex on post.title and post.excerpt. Be careful with HTML escaping.
  • Support faceted suggestions: accept taxonomy or post_type parameters and pass them to the REST query.
  • Prefetch or warm caches for popular queries using WP Cron or transient builders.
  • Use server-side fuzzy matching or ElasticPress/Algolia for larger sites to provide fast, relevant results at scale.
  • Use intersection observer to lazy-load images in suggestions for performance.

Debugging tips

  • Open browser DevTools Network tab to inspect the REST request and JSON response.
  • Inspect server logs for slow WP_Query or DB slow queries if search feels sluggish.
  • Temporarily increase per_page to verify returned data, then reduce for production.
  • Use WP_REST_Response and WP_Error to return structured errors from the REST callback.

Putting it together — checklist

  1. Create minimal HTML elements (input results container).
  2. Decide: built-in endpoint (fast) or custom endpoint (control).
  3. Register/prepare REST endpoint if custom sanitize input and optionally cache server-side.
  4. Enqueue and localize scripts pass rest_url and nonce if needed.
  5. Implement client-side debounce, fetch with AbortController, and in-memory caching.
  6. Render accessible results with ARIA roles and keyboard handling.
  7. Style results and test on mobile/desktop, different network speeds.
  8. Review security, rate limiting, and permissions.

Useful links

Final recommendations

For a small site, start with the built-in wp/v2/posts search and implement a debounced fetch for instant suggestions. For medium to large sites pick a custom REST route or an external search platform (Elasticsearch, Algolia) to scale and add relevancy. Always sanitize inputs, limit results, and implement caching strategies both client- and server-side to reduce load and improve responsiveness.



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *