All articles

WordPress development

Instant search-as-you-type for a WordPress glossary

By Rolando Escobar, WordPress developer for agencies · · 4 min read

When someone needs a definition in the middle of a phone call, a search that reloads the page is too slow. A live search that floods the server, shows results out of order or yanks keyboard focus around is worse.

The NaturalSlim Resource Center, a private knowledge base for a call-center team, has dictionaries that agents search as they type, with AJAX results. This is a tutorial for building that kind of search in WordPress: a REST endpoint with validated arguments, a debounced request that cancels stale calls, a query that stays fast, and results that work with a keyboard and a screen reader. It isn’t the project’s code.

REST endpoint or admin-ajax

Both work. For a read-only search, a REST route is the better fit:

  • Arguments are declared, validated and sanitized before your callback runs.
  • It’s a GET request, so on a public glossary the browser or a CDN can cache results.
  • Access control lives in permission_callback, which WordPress requires for every route.
  • admin-ajax.php loads the admin side of WordPress and fires admin_init, where some plugins do expensive work.

admin-ajax still makes sense when you’re extending code that already uses it. For a related pattern, filtering a custom post type by keyword and taxonomy, see my open-source CPT-Ajax-Filter-Search plugin.

Register a route with strict arguments

The glossary here is a custom post type called rw_term. The route takes one argument, q, and the script receives the URL and a REST nonce:

add_action( 'rest_api_init', function (): void {
	register_rest_route(
		'rw-glossary/v1',
		'/search',
		array(
			'methods'             => WP_REST_Server::READABLE,
			'callback'            => 'rw_glossary_search',
			'permission_callback' => fn() => current_user_can( 'read' ), // Use '__return_true' for a public glossary.
			'args'                => array(
				'q' => array(
					'type'              => 'string',
					'required'          => true,
					'minLength'         => 2,
					'maxLength'         => 60,
					'validate_callback' => 'rest_validate_request_arg',
					'sanitize_callback' => 'sanitize_text_field',
				),
			),
		)
	);
} );

add_action( 'wp_enqueue_scripts', function (): void {
	wp_enqueue_script( 'rw-glossary', plugins_url( 'glossary.js', __FILE__ ), array(), '1.0.0', array( 'in_footer' => true ) );
	wp_localize_script( 'rw-glossary', 'rwGlossary', array(
		'endpoint' => rest_url( 'rw-glossary/v1/search' ),
		'nonce'    => wp_create_nonce( 'wp_rest' ),
		'i18n'     => array(
			'none'  => __( 'No terms found.', 'rw-glossary' ),
			'one'   => __( '1 term found.', 'rw-glossary' ),
			'many'  => __( '%d terms found.', 'rw-glossary' ),
			'error' => __( 'Search is unavailable. Try again in a moment.', 'rw-glossary' ),
		),
	) );
} );

Two details in there trip people up. First, the validate_callback line: once you set your own sanitize_callback, WordPress no longer applies the schema rules (minLength, maxLength) for you, so you point validation back at rest_validate_request_arg. Second, the nonce: with cookie authentication, a REST request without an X-WP-Nonce header is treated as logged out, even when the user is signed in. On a private site, that means a 401 on every search. The REST API authentication docs explain why.

A quick check before you write any JavaScript: request the route with q=m and you should get a 400 response with the code rest_invalid_param and the message “q must be at least 2 characters long.” If that request returns results instead, validation isn’t running.

A query that stays fast

For a glossary, matching the title is what users expect, and it’s much cheaper than WordPress’s default search, which looks through titles, excerpts and content. The query below finds titles that contain the text and ranks titles that start with it first:

function rw_glossary_search( WP_REST_Request $request ): WP_REST_Response {
	global $wpdb;

	$q         = $request['q'];
	$cache_key = md5( $q ) . ':' . wp_cache_get_last_changed( 'posts' );
	$results   = wp_cache_get( $cache_key, 'rw_glossary' );

	if ( false === $results ) {
		$like = $wpdb->esc_like( $q );
		$ids  = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT ID FROM {$wpdb->posts}
				WHERE post_type = 'rw_term' AND post_status = 'publish' AND post_title LIKE %s
				ORDER BY ( post_title LIKE %s ) DESC, post_title ASC
				LIMIT 10",
				'%' . $like . '%',
				$like . '%'
			)
		);

		$results = array();
		if ( $ids ) { // An empty post__in would return every term.
			$posts = get_posts( array( 'post_type' => 'rw_term', 'post__in' => $ids, 'orderby' => 'post__in', 'posts_per_page' => 10 ) );
			foreach ( $posts as $post ) {
				$results[] = array(
					'title'   => rw_glossary_plain( get_the_title( $post ) ),
					'excerpt' => rw_glossary_plain( wp_trim_words( get_the_excerpt( $post ), 20 ) ),
					'link'    => get_permalink( $post ),
				);
			}
		}
		wp_cache_set( $cache_key, $results, 'rw_glossary', 10 * MINUTE_IN_SECONDS );
	}

	return rest_ensure_response( $results );
}

// The browser inserts these with textContent, so send plain text, not HTML entities.
function rw_glossary_plain( string $html ): string {
	return html_entity_decode( wp_strip_all_tags( $html ), ENT_QUOTES, 'UTF-8' );
}

Notes on the query:

  • $wpdb->esc_like() comes before prepare(). Without it, a user typing % or _ would turn them into wildcards.
  • Accents are handled by the database. With the collations WordPress normally uses, such as utf8mb4_unicode_520_ci, LIKE ignores case and accents, so “metabolico” finds “metabólico”.
  • The second query primes caches. get_posts() loads all ten posts at once, so the permalinks and excerpts don’t cost a query each.
  • post_title has no index. A few thousand terms scan in milliseconds. For tens of thousands, move the terms to a custom table with an indexed column. A prefix search (LIKE 'abc%') can use that index; a contains search can’t, so consider a FULLTEXT index at that size.

If you’d rather stay with WP_Query, its search_columns argument (WordPress 6.2 and later) limits a search to post_title. You give up the prefix-first ranking, though, and WordPress splits the phrase into separate words that must all match.

The cache key includes wp_cache_get_last_changed( 'posts' ), which changes whenever a post is saved, so editing a term invalidates old results without any cleanup code. Without a persistent object cache, wp_cache_* only lasts for the current request, and that’s fine: the query is cheap. Don’t swap in transients here. One row per search string would fill wp_options with junk.

Accessible markup

Start with a real search form that works without JavaScript, then add live results on top:

<form role="search" action="/" method="get" data-glossary>
  <input type="hidden" name="post_type" value="rw_term">
  <label for="rw-glossary-q">Search the glossary</label>
  <input id="rw-glossary-q" name="s" type="search" autocomplete="off"
         aria-describedby="rw-glossary-hint" aria-controls="rw-glossary-results">
  <p id="rw-glossary-hint">Results update as you type. Enter at least 2 characters.</p>

  <p id="rw-glossary-status" role="status" aria-live="polite" aria-atomic="true"></p>
  <ul id="rw-glossary-results" aria-label="Glossary results"></ul>
</form>

The visible label, the hint linked with aria-describedby and the polite status line do most of the work. The results are a plain list of links, not an ARIA combobox. A combobox with arrow-key selection is a bigger commitment; if you need one, follow the APG combobox pattern completely rather than half of it.

Debounce, cancel and render safely

const form = document.querySelector('[data-glossary]');

if (form) {
  const input = form.querySelector('input[type="search"]');
  const list = form.querySelector('#rw-glossary-results');
  const status = form.querySelector('#rw-glossary-status');
  const { endpoint, nonce, i18n } = window.rwGlossary;
  let controller;
  let timer;

  const render = (items) => {
    list.replaceChildren(...items.map((item) => {
      const li = document.createElement('li');
      const link = document.createElement('a');
      link.href = item.link;
      link.textContent = item.title;
      li.append(link);
      if (item.excerpt) li.append(document.createTextNode(` ${item.excerpt}`));
      return li;
    }));
    const n = items.length;
    status.textContent = n === 0 ? i18n.none : n === 1 ? i18n.one : i18n.many.replace('%d', n);
  };

  const search = async () => {
    const q = input.value.trim();
    controller?.abort(); // Cancel the request for the previous keystroke.
    if (q.length < 2) {
      list.replaceChildren();
      status.textContent = '';
      return;
    }
    controller = new AbortController();
    const url = new URL(endpoint);
    url.searchParams.set('q', q);
    try {
      const response = await fetch(url, { headers: { 'X-WP-Nonce': nonce }, signal: controller.signal });
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      render(await response.json());
    } catch (error) {
      if (error.name !== 'AbortError') status.textContent = i18n.error;
    }
  };

  input.addEventListener('input', () => {
    clearTimeout(timer);
    timer = setTimeout(search, 250);
  });
  form.addEventListener('submit', (event) => {
    event.preventDefault();
    clearTimeout(timer);
    search();
  });
  input.addEventListener('keydown', (event) => {
    if (event.key === 'ArrowDown' && list.querySelector('a')) {
      event.preventDefault();
      list.querySelector('a').focus();
    }
  });
}

What each piece prevents:

  • The 250 ms debounce sends one request when the user pauses, not one per keystroke.
  • AbortController cancels the previous request, so a slow answer for “met” can’t overwrite the results for “metab”.
  • textContent and createElement mean a term title can never inject HTML, even if an editor pastes something odd.
  • Focus never moves on its own. Results appear while the cursor stays in the field. Only the user’s Down Arrow moves focus to the first result, and Tab works as usual.
  • The status line announces the count, once, politely. Screen reader users know results exist without having the whole list read out.

What to watch out for

  • Make focus visible on result links with a :focus-visible outline. Browser defaults often disappear against custom backgrounds.
  • If the count announcements feel chatty for fast typists, update the status line on a slightly longer delay than the search itself.
  • Keep the minimum length at two characters or more. One-letter searches return noise and cost the most.
  • Pressing Enter runs the search immediately. Without JavaScript, the same form falls back to the normal WordPress search for the rw_term post type.
  • On a public glossary, send Cache-Control headers on the REST response so a CDN can absorb repeated queries. On a private one, don’t.

This is the kind of work I take on for clients: Plugins and integrations