ajax-search.php

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

Leave a Reply

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