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
- User types in an input field.
- A debounced JS handler sends requests to the REST API using fetch().
- Server returns JSON results (posts or custom response).
- 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
- Create minimal HTML elements (input results container).
- Decide: built-in endpoint (fast) or custom endpoint (control).
- Register/prepare REST endpoint if custom sanitize input and optionally cache server-side.
- Enqueue and localize scripts pass rest_url and nonce if needed.
- Implement client-side debounce, fetch with AbortController, and in-memory caching.
- Render accessible results with ARIA roles and keyboard handling.
- Style results and test on mobile/desktop, different network speeds.
- 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 🙂 |