I’ll build this as a strong ajax-search.php module for RX Theme: secure AJAX hooks, nonce checking, sanitization, post-type support, relevance ordering, highlighted results, caching, suggestions, and clean JSON responses.
Below is an advanced /inc/ajax/ajax-search.php file for RX Theme. It includes:
AJAX hooks for logged-in and guest users, nonce security, input sanitization, rate limiting, transient cache, post/page/custom post type support, category/tag filtering, thumbnail, author, date, excerpt, highlighted keyword, popular fallback, suggestions, and clean JSON response.
WordPress uses check_ajax_referer() to verify AJAX nonces and wp_send_json_success() / wp_send_json_error() to return proper JSON responses.
<?php
/**
* RX Theme AJAX Search
*
* File: /inc/ajax/ajax-search.php
*
* Advanced AJAX live search system for RX Theme.
*
* Features:
* - Logged-in and guest AJAX support
* - Nonce verification
* - Sanitized input
* - Rate limiting
* - Transient caching
* - Search in posts, pages, and custom post types
* - Category, tag, taxonomy support
* - Result highlighting
* - Thumbnail support
* - Author/date/excerpt support
* - Popular fallback posts
* - Search suggestions
* - Clean JSON response
*
* @package RX_Theme
*/
defined( 'ABSPATH' ) || exit;
if ( ! defined( 'RX_THEME_AJAX_SEARCH_NONCE_ACTION' ) ) {
define( 'RX_THEME_AJAX_SEARCH_NONCE_ACTION', 'rx_theme_ajax_search_nonce' );
}
if ( ! defined( 'RX_THEME_AJAX_SEARCH_NONCE_NAME' ) ) {
define( 'RX_THEME_AJAX_SEARCH_NONCE_NAME', 'nonce' );
}
if ( ! defined( 'RX_THEME_AJAX_SEARCH_ACTION' ) ) {
define( 'RX_THEME_AJAX_SEARCH_ACTION', 'rx_theme_ajax_search' );
}
/**
* Register AJAX actions.
*/
add_action( 'wp_ajax_' . RX_THEME_AJAX_SEARCH_ACTION, 'rx_theme_ajax_search_handler' );
add_action( 'wp_ajax_nopriv_' . RX_THEME_AJAX_SEARCH_ACTION, 'rx_theme_ajax_search_handler' );
/**
* Optional helper: localize AJAX search data.
*
* Call this from your enqueue file after registering/enqueuing your search JS.
*
* Example:
* wp_localize_script(
* 'rx-theme-search',
* 'rxThemeSearch',
* rx_theme_ajax_search_localized_data()
* );
*
* @return array
*/
function rx_theme_ajax_search_localized_data() {
return array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'action' => RX_THEME_AJAX_SEARCH_ACTION,
'nonce' => wp_create_nonce( RX_THEME_AJAX_SEARCH_NONCE_ACTION ),
'minChars' => (int) apply_filters( 'rx_theme_ajax_search_min_chars', 2 ),
'delay' => (int) apply_filters( 'rx_theme_ajax_search_delay', 250 ),
'maxResults' => (int) apply_filters( 'rx_theme_ajax_search_default_limit', 8 ),
'noResults' => esc_html__( 'No results found.', 'rx-theme' ),
'searching' => esc_html__( 'Searching...', 'rx-theme' ),
'viewAllText' => esc_html__( 'View all results', 'rx-theme' ),
);
}
/**
* Main AJAX search handler.
*
* Expected POST parameters:
* - nonce
* - keyword
* - post_type
* - limit
* - page
* - category
* - tag
* - taxonomy
* - term
*/
function rx_theme_ajax_search_handler() {
$nonce_check = check_ajax_referer(
RX_THEME_AJAX_SEARCH_NONCE_ACTION,
RX_THEME_AJAX_SEARCH_NONCE_NAME,
false
);
if ( false === $nonce_check ) {
wp_send_json_error(
array(
'message' => esc_html__( 'Security check failed. Please refresh the page and try again.', 'rx-theme' ),
'code' => 'invalid_nonce',
),
403
);
}
if ( ! rx_theme_ajax_search_rate_limit_passed() ) {
wp_send_json_error(
array(
'message' => esc_html__( 'Too many search requests. Please wait a moment and try again.', 'rx-theme' ),
'code' => 'rate_limited',
),
429
);
}
$keyword = isset( $_POST['keyword'] )
? sanitize_text_field( wp_unslash( $_POST['keyword'] ) )
: '';
$keyword = trim( $keyword );
$min_chars = (int) apply_filters( 'rx_theme_ajax_search_min_chars', 2 );
if ( strlen( $keyword ) < $min_chars ) {
wp_send_json_success(
array(
'results' => array(),
'suggestions' => array(),
'total' => 0,
'message' => sprintf(
/* translators: %d: minimum characters */
esc_html__( 'Please type at least %d characters.', 'rx-theme' ),
$min_chars
),
'code' => 'too_short',
)
);
}
$limit = isset( $_POST['limit'] ) ? absint( $_POST['limit'] ) : 8;
$limit = max( 1, min( $limit, (int) apply_filters( 'rx_theme_ajax_search_max_limit', 20 ) ) );
$page = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1;
$page = max( 1, $page );
$post_type = isset( $_POST['post_type'] )
? sanitize_text_field( wp_unslash( $_POST['post_type'] ) )
: 'any';
$category = isset( $_POST['category'] )
? sanitize_text_field( wp_unslash( $_POST['category'] ) )
: '';
$tag = isset( $_POST['tag'] )
? sanitize_text_field( wp_unslash( $_POST['tag'] ) )
: '';
$taxonomy = isset( $_POST['taxonomy'] )
? sanitize_key( wp_unslash( $_POST['taxonomy'] ) )
: '';
$term = isset( $_POST['term'] )
? sanitize_text_field( wp_unslash( $_POST['term'] ) )
: '';
$cache_key = rx_theme_ajax_search_cache_key(
array(
'keyword' => $keyword,
'limit' => $limit,
'page' => $page,
'post_type' => $post_type,
'category' => $category,
'tag' => $tag,
'taxonomy' => $taxonomy,
'term' => $term,
)
);
$cache_enabled = (bool) apply_filters( 'rx_theme_ajax_search_cache_enabled', true );
$cached = $cache_enabled ? get_transient( $cache_key ) : false;
if ( false !== $cached ) {
wp_send_json_success( $cached );
}
$query_args = rx_theme_ajax_search_build_query_args(
array(
'keyword' => $keyword,
'limit' => $limit,
'page' => $page,
'post_type' => $post_type,
'category' => $category,
'tag' => $tag,
'taxonomy' => $taxonomy,
'term' => $term,
)
);
$search_query = new WP_Query( $query_args );
$results = array();
if ( $search_query->have_posts() ) {
while ( $search_query->have_posts() ) {
$search_query->the_post();
$results[] = rx_theme_ajax_search_format_result( get_the_ID(), $keyword );
}
}
wp_reset_postdata();
$suggestions = rx_theme_ajax_search_get_suggestions( $keyword );
$response = array(
'results' => $results,
'suggestions' => $suggestions,
'total' => (int) $search_query->found_posts,
'maxPages' => (int) $search_query->max_num_pages,
'currentPage' => (int) $page,
'hasMore' => $page < (int) $search_query->max_num_pages,
'searchUrl' => esc_url_raw( add_query_arg( 's', rawurlencode( $keyword ), home_url( '/' ) ) ),
'viewAllLabel' => esc_html__( 'View all search results', 'rx-theme' ),
'keyword' => esc_html( $keyword ),
'fallback' => false,
);
if ( empty( $results ) ) {
$response['fallback'] = true;
$response['popular'] = rx_theme_ajax_search_get_popular_fallback_posts( $limit );
$response['message'] = esc_html__( 'No direct match found. You may like these popular articles.', 'rx-theme' );
} else {
$response['message'] = esc_html__( 'Search results loaded successfully.', 'rx-theme' );
}
$response = apply_filters( 'rx_theme_ajax_search_response', $response, $keyword, $query_args );
if ( $cache_enabled ) {
$cache_duration = (int) apply_filters( 'rx_theme_ajax_search_cache_duration', 5 * MINUTE_IN_SECONDS );
set_transient( $cache_key, $response, $cache_duration );
}
wp_send_json_success( $response );
}
/**
* Build WP_Query arguments.
*
* @param array $request Request data.
* @return array
*/
function rx_theme_ajax_search_build_query_args( $request ) {
$post_types = rx_theme_ajax_search_allowed_post_types( $request['post_type'] );
$args = array(
'post_type' => $post_types,
'post_status' => 'publish',
's' => $request['keyword'],
'posts_per_page' => $request['limit'],
'paged' => $request['page'],
'ignore_sticky_posts' => true,
'no_found_rows' => false,
'update_post_meta_cache' => true,
'update_post_term_cache' => true,
'orderby' => 'relevance',
'order' => 'DESC',
);
$tax_query = array();
if ( ! empty( $request['category'] ) ) {
$tax_query[] = array(
'taxonomy' => 'category',
'field' => is_numeric( $request['category'] ) ? 'term_id' : 'slug',
'terms' => is_numeric( $request['category'] ) ? absint( $request['category'] ) : sanitize_title( $request['category'] ),
);
}
if ( ! empty( $request['tag'] ) ) {
$tax_query[] = array(
'taxonomy' => 'post_tag',
'field' => is_numeric( $request['tag'] ) ? 'term_id' : 'slug',
'terms' => is_numeric( $request['tag'] ) ? absint( $request['tag'] ) : sanitize_title( $request['tag'] ),
);
}
if ( ! empty( $request['taxonomy'] ) && ! empty( $request['term'] ) && taxonomy_exists( $request['taxonomy'] ) ) {
$tax_query[] = array(
'taxonomy' => sanitize_key( $request['taxonomy'] ),
'field' => is_numeric( $request['term'] ) ? 'term_id' : 'slug',
'terms' => is_numeric( $request['term'] ) ? absint( $request['term'] ) : sanitize_title( $request['term'] ),
);
}
if ( ! empty( $tax_query ) ) {
$args['tax_query'] = array_merge(
array(
'relation' => 'AND',
),
$tax_query
);
}
return apply_filters( 'rx_theme_ajax_search_query_args', $args, $request );
}
/**
* Get allowed searchable post types.
*
* @param string $requested_post_type Requested post type.
* @return array
*/
function rx_theme_ajax_search_allowed_post_types( $requested_post_type = 'any' ) {
$default = array( 'post', 'page' );
$public_post_types = get_post_types(
array(
'public' => true,
'exclude_from_search' => false,
),
'names'
);
$allowed = ! empty( $public_post_types ) ? array_values( $public_post_types ) : $default;
/**
* You can control searchable post types:
*
* add_filter( 'rx_theme_ajax_search_allowed_post_types', function( $types ) {
* return array( 'post', 'page', 'disease', 'doctor' );
* } );
*/
$allowed = apply_filters( 'rx_theme_ajax_search_allowed_post_types', $allowed );
if ( 'any' === $requested_post_type || empty( $requested_post_type ) ) {
return $allowed;
}
if ( in_array( $requested_post_type, $allowed, true ) ) {
return array( $requested_post_type );
}
return $default;
}
/**
* Format a single search result.
*
* @param int $post_id Post ID.
* @param string $keyword Search keyword.
* @return array
*/
function rx_theme_ajax_search_format_result( $post_id, $keyword ) {
$post_type_object = get_post_type_object( get_post_type( $post_id ) );
$title = get_the_title( $post_id );
$raw_excerpt = has_excerpt( $post_id )
? get_the_excerpt( $post_id )
: wp_trim_words( wp_strip_all_tags( get_post_field( 'post_content', $post_id ) ), 24, '...' );
$result = array(
'id' => (int) $post_id,
'title' => esc_html( $title ),
'titleHighlighted'=> rx_theme_ajax_search_highlight_keyword( $title, $keyword ),
'excerpt' => esc_html( $raw_excerpt ),
'excerptHighlighted' => rx_theme_ajax_search_highlight_keyword( $raw_excerpt, $keyword ),
'url' => esc_url_raw( get_permalink( $post_id ) ),
'type' => esc_html( get_post_type( $post_id ) ),
'typeLabel' => $post_type_object ? esc_html( $post_type_object->labels->singular_name ) : esc_html__( 'Content', 'rx-theme' ),
'date' => esc_html( get_the_date( get_option( 'date_format' ), $post_id ) ),
'dateISO' => esc_attr( get_the_date( 'c', $post_id ) ),
'author' => esc_html( get_the_author_meta( 'display_name', (int) get_post_field( 'post_author', $post_id ) ) ),
'thumbnail' => rx_theme_ajax_search_get_thumbnail( $post_id ),
'categories' => rx_theme_ajax_search_get_terms( $post_id, 'category' ),
'tags' => rx_theme_ajax_search_get_terms( $post_id, 'post_tag' ),
'readingTime' => rx_theme_ajax_search_reading_time( $post_id ),
);
return apply_filters( 'rx_theme_ajax_search_result_item', $result, $post_id, $keyword );
}
/**
* Highlight keyword inside text.
*
* @param string $text Text.
* @param string $keyword Keyword.
* @return string
*/
function rx_theme_ajax_search_highlight_keyword( $text, $keyword ) {
$text = wp_strip_all_tags( $text );
$keyword = trim( wp_strip_all_tags( $keyword ) );
if ( '' === $keyword ) {
return esc_html( $text );
}
$escaped_text = esc_html( $text );
$escaped_keyword = preg_quote( esc_html( $keyword ), '/' );
$highlighted = preg_replace(
'/' . $escaped_keyword . '/i',
'<mark class="rx-search-highlight">$0</mark>',
$escaped_text
);
return wp_kses(
$highlighted,
array(
'mark' => array(
'class' => array(),
),
)
);
}
/**
* Get post thumbnail data.
*
* @param int $post_id Post ID.
* @return array|null
*/
function rx_theme_ajax_search_get_thumbnail( $post_id ) {
if ( ! has_post_thumbnail( $post_id ) ) {
return null;
}
$thumbnail_id = get_post_thumbnail_id( $post_id );
$src = wp_get_attachment_image_src( $thumbnail_id, 'thumbnail' );
$alt = get_post_meta( $thumbnail_id, '_wp_attachment_image_alt', true );
if ( empty( $src[0] ) ) {
return null;
}
return array(
'id' => (int) $thumbnail_id,
'url' => esc_url_raw( $src[0] ),
'width' => isset( $src[1] ) ? (int) $src[1] : 0,
'height' => isset( $src[2] ) ? (int) $src[2] : 0,
'alt' => esc_attr( $alt ? $alt : get_the_title( $post_id ) ),
);
}
/**
* Get simple term list.
*
* @param int $post_id Post ID.
* @param string $taxonomy Taxonomy.
* @return array
*/
function rx_theme_ajax_search_get_terms( $post_id, $taxonomy ) {
if ( ! taxonomy_exists( $taxonomy ) ) {
return array();
}
$terms = get_the_terms( $post_id, $taxonomy );
if ( empty( $terms ) || is_wp_error( $terms ) ) {
return array();
}
$data = array();
foreach ( $terms as $term ) {
$data[] = array(
'id' => (int) $term->term_id,
'name' => esc_html( $term->name ),
'slug' => esc_attr( $term->slug ),
'url' => esc_url_raw( get_term_link( $term ) ),
);
}
return $data;
}
/**
* Estimate reading time.
*
* @param int $post_id Post ID.
* @return string
*/
function rx_theme_ajax_search_reading_time( $post_id ) {
$content = wp_strip_all_tags( get_post_field( 'post_content', $post_id ) );
$word_count = str_word_count( $content );
$minutes = max( 1, (int) ceil( $word_count / 200 ) );
return sprintf(
/* translators: %d: reading minutes */
_n( '%d min read', '%d min read', $minutes, 'rx-theme' ),
$minutes
);
}
/**
* Search suggestions from tags/categories.
*
* @param string $keyword Keyword.
* @return array
*/
function rx_theme_ajax_search_get_suggestions( $keyword ) {
$suggestions = array();
$terms = get_terms(
array(
'taxonomy' => array( 'category', 'post_tag' ),
'hide_empty' => true,
'number' => 6,
'search' => $keyword,
)
);
if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) {
foreach ( $terms as $term ) {
$suggestions[] = array(
'id' => (int) $term->term_id,
'name' => esc_html( $term->name ),
'slug' => esc_attr( $term->slug ),
'taxonomy' => esc_attr( $term->taxonomy ),
'url' => esc_url_raw( get_term_link( $term ) ),
);
}
}
return apply_filters( 'rx_theme_ajax_search_suggestions', $suggestions, $keyword );
}
/**
* Popular fallback posts.
*
* This uses comment count by default.
* Later, you can replace it with post views if your RX Theme adds view tracking.
*
* @param int $limit Limit.
* @return array
*/
function rx_theme_ajax_search_get_popular_fallback_posts( $limit = 5 ) {
$query = new WP_Query(
array(
'post_type' => rx_theme_ajax_search_allowed_post_types( 'post' ),
'post_status' => 'publish',
'posts_per_page' => absint( $limit ),
'orderby' => 'comment_count',
'order' => 'DESC',
'ignore_sticky_posts' => true,
'no_found_rows' => true,
)
);
$popular = array();
if ( $query->have_posts() ) {
while ( $query->have_posts() ) {
$query->the_post();
$popular[] = rx_theme_ajax_search_format_result( get_the_ID(), '' );
}
}
wp_reset_postdata();
return apply_filters( 'rx_theme_ajax_search_popular_fallback_posts', $popular, $limit );
}
/**
* Create cache key.
*
* @param array $data Data.
* @return string
*/
function rx_theme_ajax_search_cache_key( $data ) {
return 'rx_ajax_search_' . md5( wp_json_encode( $data ) );
}
/**
* Basic AJAX rate limit.
*
* Helps protect admin-ajax.php from too many repeated requests.
*
* @return bool
*/
function rx_theme_ajax_search_rate_limit_passed() {
$enabled = (bool) apply_filters( 'rx_theme_ajax_search_rate_limit_enabled', true );
if ( ! $enabled ) {
return true;
}
$ip = rx_theme_ajax_search_get_client_ip();
if ( empty( $ip ) ) {
return true;
}
$key = 'rx_ajax_search_rate_' . md5( $ip );
$count = (int) get_transient( $key );
$maximum = (int) apply_filters( 'rx_theme_ajax_search_rate_limit_max_requests', 40 );
$window = (int) apply_filters( 'rx_theme_ajax_search_rate_limit_window', MINUTE_IN_SECONDS );
if ( $count >= $maximum ) {
return false;
}
set_transient( $key, $count + 1, $window );
return true;
}
/**
* Get client IP carefully.
*
* @return string
*/
function rx_theme_ajax_search_get_client_ip() {
$ip_keys = array(
'HTTP_CF_CONNECTING_IP',
'HTTP_X_FORWARDED_FOR',
'REMOTE_ADDR',
);
foreach ( $ip_keys as $key ) {
if ( empty( $_SERVER[ $key ] ) ) {
continue;
}
$value = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) );
if ( false !== strpos( $value, ',' ) ) {
$parts = explode( ',', $value );
$value = trim( $parts[0] );
}
if ( filter_var( $value, FILTER_VALIDATE_IP ) ) {
return $value;
}
}
return '';
}
/**
* Optional: clear AJAX search cache when content changes.
*/
add_action( 'save_post', 'rx_theme_ajax_search_clear_related_cache' );
add_action( 'deleted_post', 'rx_theme_ajax_search_clear_related_cache' );
add_action( 'edited_terms', 'rx_theme_ajax_search_clear_related_cache' );
/**
* Cache clearing placeholder.
*
* WordPress transients with unknown dynamic keys are not easy to bulk delete
* without direct DB queries. For safety and performance, cache duration is short.
*
* If you use object cache like Redis, short transient expiration is enough.
*
* @return void
*/
function rx_theme_ajax_search_clear_related_cache() {
do_action( 'rx_theme_ajax_search_cache_should_clear' );
}
Add this file in your theme loader, for example inside functions.php or /inc/core/theme-setup.php:
require_once get_template_directory() . '/inc/ajax/ajax-search.php';
For your JavaScript AJAX request, use this action name:
action: 'rx_theme_ajax_search'
And nonce name:
nonce: rxThemeSearch.nonce
Best structure for RX Theme:
rx-theme/
└── inc/
└── ajax/
└── ajax-search.php
This file is advanced enough for your theme now, but later you ca
n connect it with a custom search UI file like:
assets/js/modules/ajax-search.js
assets/css/components/search.css