AJAX search and taxonomy filters for a custom post type
By Rolando Escobar, WordPress developer for agencies · · 6 min read
A directory with a few hundred entries is only useful if people can narrow it down. Visitors want to type a name or pick a category and see matching results right away, without a page reload and without losing their place.
I built CPT Ajax Filter & Search for that job, for a client’s vendor directory: an AJAX search that filters a custom post type by keyword and taxonomy, with an ACF field for each vendor’s external link. This article walks through how the plugin works, what to tighten when you reuse the pattern on a new project, and how to extend it to filter by ACF fields.
How the plugin works
The plugin is one PHP file, one script and one stylesheet. A shortcode such as [cpt_ajax_filter post_type="vendor" taxonomy="vendor_category"] prints a search field, a button for every term in the taxonomy and a first set of results rendered on the server.
- The script waits 400 ms after the last keystroke (a debounce), cancels any request still in flight and posts the search text and the selected term to
admin-ajax.phpwith a nonce. - The handler verifies the nonce with
check_ajax_referer(), sanitizes the input, runs aWP_Querywith atax_queryand thessearch parameter, and returns the card HTML throughwp_send_json_success(). - Each card reads an ACF URL field called
external_link. When it has a value, the card links to the vendor’s own site in a new tab. Otherwise it links to the post.
That core holds up. If you reuse it, change three things: the server should decide what can be queried, results should be paged, and screen reader users should hear when the list changes. If you also need to filter by ACF fields, the same structure takes that extension with a few extra lines.
Keep the query rules on the server
A common mistake with this pattern is to trust the request: if the post type, taxonomy and page size travel from data- attributes to the handler and it uses them as sent, anyone can edit those attributes in developer tools. A visitor could then ask for any post type that has published posts, including ones you never meant to list, and posts_per_page=-1 would let a single request pull every row. Sanitizing a string doesn’t make it an allowed value.
The fix is to send a short key and look up the real configuration on the server. The configuration below also carries the ACF extension, which the plugin doesn’t include: a list of true/false fields visitors may filter by. offers_discount is a hypothetical field you’d swap for your own, and you can drop the flags parts entirely if keyword and taxonomy are enough.
/**
* Filter configurations. The browser only ever sends the array key.
*/
function rw_filter_configs(): array {
return apply_filters( 'rw_filter_configs', [
'vendors' => [
'post_type' => 'vendor',
'taxonomy' => 'vendor_category',
'flags' => [ 'offers_discount' ], // ACF true/false fields.
'per_page' => 12,
],
] );
}
/**
* Builds WP_Query arguments from a config and untrusted request input.
*/
function rw_filter_query_args( array $config, array $input ): array {
$args = [
'post_type' => $config['post_type'],
'post_status' => 'publish',
'posts_per_page' => $config['per_page'],
'paged' => max( 1, absint( $input['page'] ?? 1 ) ),
's' => sanitize_text_field( $input['search'] ?? '' ),
'orderby' => 'title',
'order' => 'ASC',
];
$terms = array_filter( (array) ( $input['terms'] ?? [] ), 'is_string' );
$terms = array_filter( array_map( 'sanitize_title', $terms ) );
if ( $terms ) {
$args['tax_query'] = [
[
'taxonomy' => $config['taxonomy'],
'field' => 'slug',
'terms' => $terms,
'operator' => 'IN', // Any selected term. Use 'AND' to require all.
],
];
}
$flags = (array) ( $input['flags'] ?? [] );
$meta = [];
foreach ( $config['flags'] as $field ) {
if ( ! empty( $flags[ $field ] ) ) {
$meta[] = [ 'key' => $field, 'value' => '1' ]; // ACF saves true/false as "1" or "0".
}
}
if ( $meta ) {
$args['meta_query'] = array_merge( [ 'relation' => 'AND' ], $meta );
}
return $args;
}
ACF stores simple fields as regular post meta under the field name, which is why a plain meta_query works. A true/false field saves "1" or "0", and a select field saves its value as a string. Checkbox and multi-select fields save a serialized array, so they need a LIKE comparison against the quoted value, which is slower and less exact. The WP_Query reference covers the full tax_query and meta_query syntax.
The AJAX handler
The handler checks the nonce, finds the configuration, runs the query and returns three values: the HTML, the total count and the number of pages. The count feeds the status message, and the page count tells the script whether to show a “Load more” button.
add_action( 'wp_ajax_rw_filter', 'rw_filter_handler' );
add_action( 'wp_ajax_nopriv_rw_filter', 'rw_filter_handler' );
function rw_filter_handler(): void {
check_ajax_referer( 'rw_filter', 'nonce' );
$configs = rw_filter_configs();
$key = sanitize_key( wp_unslash( $_POST['config'] ?? '' ) );
if ( ! isset( $configs[ $key ] ) ) {
wp_send_json_error( [ 'message' => 'Unknown directory.' ], 400 );
}
$query = new WP_Query( rw_filter_query_args( $configs[ $key ], wp_unslash( $_POST ) ) );
wp_send_json_success( [
'html' => rw_filter_cards( $query ),
'found' => (int) $query->found_posts,
'pages' => (int) $query->max_num_pages,
] );
}
function rw_filter_cards( WP_Query $query ): string {
$html = '';
foreach ( $query->posts as $post ) {
// Same rule as the plugin: an ACF "external_link" URL wins over the permalink.
$external = function_exists( 'get_field' ) ? (string) get_field( 'external_link', $post->ID ) : '';
$html .= sprintf(
'<li class="rw-card"><h3 class="rw-card__title"><a href="%1$s"%2$s>%3$s%4$s</a></h3><p>%5$s</p></li>',
esc_url( '' !== $external ? $external : get_permalink( $post ) ),
'' !== $external ? ' target="_blank" rel="noopener"' : '',
esc_html( get_the_title( $post ) ),
'' !== $external ? '<span class="screen-reader-text"> (opens in a new tab)</span>' : '',
esc_html( wp_trim_words( get_the_excerpt( $post ), 25 ) )
);
}
return $html;
}
Each card is a list item with one real link in its heading. The plugin makes the whole card clickable with a JavaScript click handler. A CSS “stretched link” (an ::after pseudo-element on the link that covers the card) gives mouse users the same large target, while keyboard and screen reader users still get a single, normal link. When the link opens a new tab, say so in the link text, as the hidden span does.
One note on the nonce. For logged-out visitors, a WordPress nonce is the same for everyone and stays valid for 12 to 24 hours. If a full-page cache serves the page for longer than that, the embedded nonce expires and every search fails with a 403. Keep the page cache lifetime under 12 hours or, since this is public read-only data, consider a REST route with strict argument validation instead. The nonces documentation explains the lifetime.
Accessible markup
The plugin’s search field relies on a placeholder, and its filter buttons only change a CSS class. A few attributes fix both:
- A visible
<label>for the search field, andtype="search". - Filter buttons that are real
<button type="button">elements witharia-pressed, grouped withrole="group"and an accessible name. - A
role="status"element, which is a polite live region, that announces “12 vendors found”. Announce the count, not the list, so a screen reader doesn’t read every card after each keystroke. aria-busyon the list while a request runs.
In the markup below, the checkbox is the optional ACF filter from the configuration above.
add_action( 'init', function (): void {
wp_register_script( 'rw-filter', plugins_url( 'assets/filter.js', __FILE__ ), [], '1.0.0', [ 'strategy' => 'defer', 'in_footer' => true ] );
wp_add_inline_script( 'rw-filter', 'window.rwFilter = ' . wp_json_encode( [
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'rw_filter' ),
] ) . ';', 'before' );
add_shortcode( 'rw_filter', 'rw_filter_shortcode' );
} );
function rw_filter_shortcode( $atts ): string {
$atts = shortcode_atts( [ 'config' => 'vendors' ], $atts, 'rw_filter' );
$key = sanitize_key( $atts['config'] );
$configs = rw_filter_configs();
if ( ! isset( $configs[ $key ] ) ) {
return '';
}
$terms = get_terms( [ 'taxonomy' => $configs[ $key ]['taxonomy'], 'hide_empty' => true ] );
$first = new WP_Query( rw_filter_query_args( $configs[ $key ], [] ) );
$id = wp_unique_id( 'rw-filter-' );
wp_enqueue_script( 'rw-filter' ); // Loads only on pages that use the shortcode.
ob_start();
?>
<div class="rw-filter" data-config="<?php echo esc_attr( $key ); ?>">
<form class="rw-filter__form" role="search">
<label for="<?php echo esc_attr( $id ); ?>"><?php esc_html_e( 'Search vendors', 'rw-filter' ); ?></label>
<input type="search" id="<?php echo esc_attr( $id ); ?>" name="search" autocomplete="off">
<div role="group" aria-label="<?php esc_attr_e( 'Filter by category', 'rw-filter' ); ?>">
<?php foreach ( is_wp_error( $terms ) ? [] : $terms as $term ) : ?>
<button type="button" aria-pressed="false" data-term="<?php echo esc_attr( $term->slug ); ?>"><?php echo esc_html( $term->name ); ?></button>
<?php endforeach; ?>
</div>
<label><input type="checkbox" name="flags[offers_discount]" value="1"> <?php esc_html_e( 'Offers a member discount', 'rw-filter' ); ?></label>
</form>
<p class="rw-filter__status" role="status" aria-live="polite"></p>
<ul class="rw-filter__results" aria-busy="false"><?php echo rw_filter_cards( $first ); // Escaped inside. ?></ul>
<button type="button" class="rw-filter__more"<?php echo $first->max_num_pages > 1 ? '' : ' hidden'; ?>><?php esc_html_e( 'Load more', 'rw-filter' ); ?></button>
</div>
<?php
return (string) ob_get_clean();
}
The shortcode enqueues its script from inside the callback, so the file loads only on pages that show the directory. The script is registered earlier with wp_register_script(), and the nonce and AJAX URL reach it through wp_add_inline_script(). The first page of results is rendered on the server, so the directory works before JavaScript runs and search engines can read the entries.
The script: debounce, cancel, announce
The plugin uses jQuery. For new code, fetch with an AbortController does the same job as aborting the jQuery request. Everything is scoped to the wrapper element, so two directories can share a page. The plugin looks its elements up by fixed IDs, which limits it to one instance per page.
document.querySelectorAll( '.rw-filter' ).forEach( ( root ) => {
const form = root.querySelector( 'form' );
const status = root.querySelector( '[role="status"]' );
const list = root.querySelector( '.rw-filter__results' );
const more = root.querySelector( '.rw-filter__more' );
let page = 1;
let timer;
let controller;
async function load( append = false ) {
controller?.abort(); // Cancel the previous request, like the plugin's xhr.abort().
controller = new AbortController();
const { signal } = controller;
const body = new FormData( form );
body.append( 'action', 'rw_filter' );
body.append( 'nonce', window.rwFilter.nonce );
body.append( 'config', root.dataset.config );
body.append( 'page', String( page ) );
root.querySelectorAll( '[aria-pressed="true"]' ).forEach( ( btn ) => body.append( 'terms[]', btn.dataset.term ) );
list.setAttribute( 'aria-busy', 'true' );
try {
const res = await fetch( window.rwFilter.ajaxUrl, { method: 'POST', body, signal } );
const json = await res.json();
if ( ! res.ok || ! json.success ) {
throw new Error( 'Request failed' );
}
const firstNew = list.children.length;
if ( append ) {
list.insertAdjacentHTML( 'beforeend', json.data.html ); // Escaped on the server.
list.children[ firstNew ]?.querySelector( 'a' )?.focus();
} else {
list.innerHTML = json.data.html;
}
more.hidden = page >= json.data.pages;
status.textContent = json.data.found ? `${ json.data.found } vendors found` : 'No vendors match these filters.';
} catch ( err ) {
if ( err.name !== 'AbortError' ) {
page -= append ? 1 : 0; // Let "Load more" retry the same page.
status.textContent = 'Search failed. Please try again.';
}
} finally {
if ( ! signal.aborted ) list.setAttribute( 'aria-busy', 'false' );
}
}
const reset = () => { page = 1; load(); };
form.addEventListener( 'input', ( e ) => {
if ( e.target.type !== 'search' ) return;
clearTimeout( timer );
timer = setTimeout( reset, 400 ); // Debounce: wait for a pause in typing.
} );
form.addEventListener( 'change', ( e ) => e.target.type === 'checkbox' && reset() );
form.addEventListener( 'submit', ( e ) => { e.preventDefault(); clearTimeout( timer ); reset(); } );
root.querySelectorAll( '[aria-pressed]' ).forEach( ( btn ) => btn.addEventListener( 'click', () => {
btn.setAttribute( 'aria-pressed', String( btn.getAttribute( 'aria-pressed' ) !== 'true' ) );
reset();
} ) );
more.addEventListener( 'click', () => { page++; load( true ); } );
} );
A few details matter here. Every filter change resets to page 1. “Load more” appends results and moves focus to the first new link, so keyboard users continue where the new content starts instead of jumping back to the top. While someone types, focus stays in the search field and only the status message changes. The HTML inserted with innerHTML comes from your own endpoint and every value in it was escaped on the server; never insert HTML from a third-party response this way. MDN’s status role reference covers how live regions are announced.
Performance limits to know about
- Meta queries don’t scale like taxonomies. Filtering on
wp_postmetavalues is fine for a few hundred posts. If a field is a main filter across thousands of posts, make it a taxonomy instead. - Search doesn’t cover ACF fields. The
sparameter searches the title, excerpt and content, and since WordPress 6.2 you can narrow it withsearch_columns. WP_Query combinessandmeta_querywith AND, so “match the title or this field” needs aposts_searchfilter. - The server sets the page size. The configuration defines it and the browser never does.
What to watch out for
- Check that
get_terms()didn’t return aWP_Errorbefore looping over it, as the plugin does. - If you keep the plugin’s staggered fade-in for new cards, turn it off under
prefers-reduced-motion: reduce. - Show “No vendors match these filters” in the status region instead of leaving an empty grid.
- Reset “Load more” whenever the filters change, or users will page through the wrong result set.
- Test with a screen reader: type a name, toggle a filter, load more, and confirm you hear the count each time.
This is the kind of work I take on for clients: Plugins and integrations