I’ll build this as a strong WordPress theme AJAX “load more” module, including nonce security, sanitization, taxonomy/search support, templates, pagination metadata, cache-friendly responses, and fallback helpers.
Below is an advanced inc/ajax/load-more.php file for your RX Theme. It includes secure AJAX load-more posts, nonce verification, guest/user hooks, search/category/tag/taxonomy support, ordering, templates, pagination metadata, cache-friendly HTML output, and helper functions.
This follows WordPress AJAX best practice: use wp_ajax_... and wp_ajax_nopriv_... for logged-in and guest users, verify AJAX nonce with check_ajax_referer(), query posts with WP_Query, and return JSON with wp_send_json_success() / wp_send_json_error().
Create this file:
<?php
/**
* RX Theme Ajax Load More
*
* File: inc/ajax/load-more.php
*
* Features:
* - Secure AJAX load more posts
* - Nonce verification
* - Supports posts, pages, custom post types
* - Supports category, tag, taxonomy, author, search, date archive
* - Supports exclude/include IDs
* - Supports sticky post handling
* - Supports custom template part
* - Supports pagination metadata
* - Supports “has more” detection
* - Supports JSON response
* - Supports guest and logged-in users
*
* @package RX_Theme
*/
defined( 'ABSPATH' ) || exit;
if ( ! defined( 'RX_LOAD_MORE_ACTION' ) ) {
define( 'RX_LOAD_MORE_ACTION', 'rx_load_more_posts' );
}
if ( ! defined( 'RX_LOAD_MORE_NONCE_ACTION' ) ) {
define( 'RX_LOAD_MORE_NONCE_ACTION', 'rx_load_more_nonce_action' );
}
if ( ! defined( 'RX_LOAD_MORE_NONCE_NAME' ) ) {
define( 'RX_LOAD_MORE_NONCE_NAME', 'nonce' );
}
/**
* Register AJAX actions.
*/
add_action( 'wp_ajax_' . RX_LOAD_MORE_ACTION, 'rx_ajax_load_more_posts' );
add_action( 'wp_ajax_nopriv_' . RX_LOAD_MORE_ACTION, 'rx_ajax_load_more_posts' );
/**
* Main AJAX callback.
*
* Expected JS POST data:
*
* action: rx_load_more_posts
* nonce: localized nonce
* page: current page number
* posts_per_page: number
* post_type: post
* order: DESC
* orderby: date
* search: optional
* category: optional category slug/id
* tag: optional tag slug/id
* taxonomy: optional taxonomy name
* term: optional term slug/id
* author: optional author ID
* template: optional template name
*/
function rx_ajax_load_more_posts() {
/*
|--------------------------------------------------------------------------
| 1. Security check
|--------------------------------------------------------------------------
|
| check_ajax_referer protects the endpoint from unwanted external requests.
|
*/
if ( ! check_ajax_referer( RX_LOAD_MORE_NONCE_ACTION, RX_LOAD_MORE_NONCE_NAME, false ) ) {
wp_send_json_error(
array(
'message' => esc_html__( 'Security check failed. Please refresh the page and try again.', 'rx-theme' ),
'code' => 'invalid_nonce',
),
403
);
}
/*
|--------------------------------------------------------------------------
| 2. Sanitize request values
|--------------------------------------------------------------------------
*/
$page = isset( $_POST['page'] ) ? absint( wp_unslash( $_POST['page'] ) ) : 1;
$posts_per_page = isset( $_POST['posts_per_page'] ) ? absint( wp_unslash( $_POST['posts_per_page'] ) ) : get_option( 'posts_per_page' );
$post_type = isset( $_POST['post_type'] ) ? sanitize_key( wp_unslash( $_POST['post_type'] ) ) : 'post';
$order = isset( $_POST['order'] ) ? strtoupper( sanitize_key( wp_unslash( $_POST['order'] ) ) ) : 'DESC';
$orderby = isset( $_POST['orderby'] ) ? sanitize_key( wp_unslash( $_POST['orderby'] ) ) : 'date';
$search = isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( $_POST['search'] ) ) : '';
$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'] ) ) : '';
$author = isset( $_POST['author'] ) ? absint( wp_unslash( $_POST['author'] ) ) : 0;
$template = isset( $_POST['template'] ) ? sanitize_key( wp_unslash( $_POST['template'] ) ) : 'card';
$context = isset( $_POST['context'] ) ? sanitize_key( wp_unslash( $_POST['context'] ) ) : 'archive';
/*
|--------------------------------------------------------------------------
| 3. Normalize values
|--------------------------------------------------------------------------
*/
$page = max( 1, $page );
$next_page = $page + 1;
$posts_per_page = rx_load_more_limit_posts_per_page( $posts_per_page );
$order = in_array( $order, array( 'ASC', 'DESC' ), true ) ? $order : 'DESC';
$orderby = rx_load_more_allowed_orderby( $orderby );
$post_type = rx_load_more_allowed_post_type( $post_type );
/*
|--------------------------------------------------------------------------
| 4. Include / exclude post IDs
|--------------------------------------------------------------------------
*/
$post__not_in = rx_load_more_parse_id_list_from_request( 'exclude' );
$post__in = rx_load_more_parse_id_list_from_request( 'include' );
/*
|--------------------------------------------------------------------------
| 5. Build WP_Query args
|--------------------------------------------------------------------------
*/
$args = array(
'post_type' => $post_type,
'post_status' => 'publish',
'posts_per_page' => $posts_per_page,
'paged' => $next_page,
'order' => $order,
'orderby' => $orderby,
'ignore_sticky_posts' => true,
'no_found_rows' => false,
'update_post_meta_cache' => true,
'update_post_term_cache' => true,
);
if ( ! empty( $search ) ) {
$args['s'] = $search;
}
if ( $author > 0 ) {
$args['author'] = $author;
}
if ( ! empty( $post__not_in ) ) {
$args['post__not_in'] = $post__not_in;
}
if ( ! empty( $post__in ) ) {
$args['post__in'] = $post__in;
$args['orderby'] = 'post__in';
}
/*
|--------------------------------------------------------------------------
| 6. Category support
|--------------------------------------------------------------------------
|
| category can be ID or slug.
|
*/
if ( ! empty( $category ) ) {
if ( is_numeric( $category ) ) {
$args['cat'] = absint( $category );
} else {
$args['category_name'] = sanitize_title( $category );
}
}
/*
|--------------------------------------------------------------------------
| 7. Tag support
|--------------------------------------------------------------------------
|
| tag can be ID or slug.
|
*/
if ( ! empty( $tag ) ) {
if ( is_numeric( $tag ) ) {
$args['tag_id'] = absint( $tag );
} else {
$args['tag'] = sanitize_title( $tag );
}
}
/*
|--------------------------------------------------------------------------
| 8. Custom taxonomy support
|--------------------------------------------------------------------------
*/
if ( ! empty( $taxonomy ) && ! empty( $term ) && taxonomy_exists( $taxonomy ) ) {
$args['tax_query'] = array(
array(
'taxonomy' => $taxonomy,
'field' => is_numeric( $term ) ? 'term_id' : 'slug',
'terms' => is_numeric( $term ) ? absint( $term ) : sanitize_title( $term ),
),
);
}
/*
|--------------------------------------------------------------------------
| 9. Date archive support
|--------------------------------------------------------------------------
*/
$date_query = rx_load_more_get_date_query_from_request();
if ( ! empty( $date_query ) ) {
$args['date_query'] = $date_query;
}
/*
|--------------------------------------------------------------------------
| 10. Meta query support
|--------------------------------------------------------------------------
|
| Keep this strict. Never accept raw meta_query from the browser.
|
*/
$meta_key = isset( $_POST['meta_key'] ) ? sanitize_key( wp_unslash( $_POST['meta_key'] ) ) : '';
$meta_value = isset( $_POST['meta_value'] ) ? sanitize_text_field( wp_unslash( $_POST['meta_value'] ) ) : '';
if ( ! empty( $meta_key ) && ! empty( $meta_value ) ) {
$args['meta_query'] = array(
array(
'key' => $meta_key,
'value' => $meta_value,
'compare' => '=',
),
);
}
/*
|--------------------------------------------------------------------------
| 11. Allow developers to modify query safely
|--------------------------------------------------------------------------
*/
$args = apply_filters( 'rx_load_more_query_args', $args, $_POST );
/*
|--------------------------------------------------------------------------
| 12. Run query
|--------------------------------------------------------------------------
*/
$query = new WP_Query( $args );
ob_start();
if ( $query->have_posts() ) {
while ( $query->have_posts() ) {
$query->the_post();
/*
* Template priority:
*
* 1. template-parts/content-{template}.php
* 2. template-parts/content.php
* 3. Built-in fallback HTML
*/
if ( rx_load_more_template_exists( $template ) ) {
get_template_part( 'template-parts/content', $template );
} elseif ( locate_template( 'template-parts/content.php' ) ) {
get_template_part( 'template-parts/content' );
} else {
rx_load_more_default_card_template();
}
}
wp_reset_postdata();
}
$html = ob_get_clean();
/*
|--------------------------------------------------------------------------
| 13. Prepare response
|--------------------------------------------------------------------------
*/
$max_pages = absint( $query->max_num_pages );
$current_page = $next_page;
$found_posts = absint( $query->found_posts );
$loaded_count = absint( $query->post_count );
$has_more = $current_page < $max_pages;
$remaining = max( 0, $found_posts - ( $current_page * $posts_per_page ) );
$button_label = $has_more ? esc_html__( 'Load More', 'rx-theme' ) : esc_html__( 'No More Posts', 'rx-theme' );
if ( empty( trim( $html ) ) ) {
wp_send_json_error(
array(
'message' => esc_html__( 'No more posts found.', 'rx-theme' ),
'code' => 'no_posts',
'html' => '',
'page' => $current_page,
'max_pages' => $max_pages,
'found_posts' => $found_posts,
'has_more' => false,
'remaining' => 0,
'button_label' => esc_html__( 'No More Posts', 'rx-theme' ),
),
200
);
}
wp_send_json_success(
array(
'message' => esc_html__( 'Posts loaded successfully.', 'rx-theme' ),
'html' => $html,
'page' => $current_page,
'next_page' => $current_page + 1,
'max_pages' => $max_pages,
'found_posts' => $found_posts,
'loaded_count' => $loaded_count,
'has_more' => $has_more,
'remaining' => $remaining,
'button_label' => $button_label,
'context' => $context,
),
200
);
}
/**
* Limit posts per page.
*
* This prevents someone from requesting 1000 posts in one AJAX request.
*
* @param int $posts_per_page Posts per page.
* @return int
*/
function rx_load_more_limit_posts_per_page( $posts_per_page ) {
$posts_per_page = absint( $posts_per_page );
if ( $posts_per_page < 1 ) {
$posts_per_page = 6;
}
$max = apply_filters( 'rx_load_more_max_posts_per_page', 24 );
if ( $posts_per_page > $max ) {
$posts_per_page = $max;
}
return $posts_per_page;
}
/**
* Validate allowed orderby values.
*
* @param string $orderby Orderby value.
* @return string
*/
function rx_load_more_allowed_orderby( $orderby ) {
$allowed = array(
'date',
'title',
'name',
'modified',
'author',
'comment_count',
'rand',
'menu_order',
'ID',
'post__in',
);
if ( in_array( $orderby, $allowed, true ) ) {
return $orderby;
}
return 'date';
}
/**
* Validate post type.
*
* @param string $post_type Post type.
* @return string
*/
function rx_load_more_allowed_post_type( $post_type ) {
$post_type = sanitize_key( $post_type );
if ( post_type_exists( $post_type ) ) {
return $post_type;
}
return 'post';
}
/**
* Parse comma-separated ID list from request.
*
* Example:
* exclude: 1,2,3
* include: 10,11,12
*
* @param string $key Request key.
* @return array
*/
function rx_load_more_parse_id_list_from_request( $key ) {
if ( empty( $_POST[ $key ] ) ) {
return array();
}
$raw = sanitize_text_field( wp_unslash( $_POST[ $key ] ) );
if ( empty( $raw ) ) {
return array();
}
$ids = array_map( 'absint', explode( ',', $raw ) );
$ids = array_filter( $ids );
return array_values( array_unique( $ids ) );
}
/**
* Build date query from AJAX request.
*
* Supports:
* year
* month
* day
*
* @return array
*/
function rx_load_more_get_date_query_from_request() {
$year = isset( $_POST['year'] ) ? absint( wp_unslash( $_POST['year'] ) ) : 0;
$month = isset( $_POST['month'] ) ? absint( wp_unslash( $_POST['month'] ) ) : 0;
$day = isset( $_POST['day'] ) ? absint( wp_unslash( $_POST['day'] ) ) : 0;
$date_query = array();
if ( $year > 0 ) {
$date_query['year'] = $year;
}
if ( $month > 0 && $month <= 12 ) {
$date_query['month'] = $month;
}
if ( $day > 0 && $day <= 31 ) {
$date_query['day'] = $day;
}
if ( empty( $date_query ) ) {
return array();
}
return array( $date_query );
}
/**
* Check whether selected template exists.
*
* @param string $template Template slug.
* @return bool
*/
function rx_load_more_template_exists( $template ) {
$template = sanitize_key( $template );
if ( empty( $template ) ) {
return false;
}
$path = 'template-parts/content-' . $template . '.php';
return (bool) locate_template( $path );
}
/**
* Default fallback post card template.
*
* This is used only if no template-parts/content.php file exists.
*
* @return void
*/
function rx_load_more_default_card_template() {
?>
<article id="post-<?php the_ID(); ?>" <?php post_class( 'rx-load-more-card' ); ?>>
<?php if ( has_post_thumbnail() ) : ?>
<a class="rx-load-more-card__thumbnail" href="<?php the_permalink(); ?>" aria-label="<?php echo esc_attr( get_the_title() ); ?>">
<?php
the_post_thumbnail(
'medium_large',
array(
'loading' => 'lazy',
'decoding' => 'async',
)
);
?>
</a>
<?php endif; ?>
<div class="rx-load-more-card__body">
<header class="rx-load-more-card__header">
<h2 class="rx-load-more-card__title">
<a href="<?php the_permalink(); ?>">
<?php the_title(); ?>
</a>
</h2>
<div class="rx-load-more-card__meta">
<span class="rx-load-more-card__date">
<?php echo esc_html( get_the_date() ); ?>
</span>
<?php if ( get_the_author() ) : ?>
<span class="rx-load-more-card__author">
<?php echo esc_html( get_the_author() ); ?>
</span>
<?php endif; ?>
</div>
</header>
<div class="rx-load-more-card__excerpt">
<?php echo wp_kses_post( wp_trim_words( get_the_excerpt(), 24, '...' ) ); ?>
</div>
<a class="rx-load-more-card__read-more" href="<?php the_permalink(); ?>">
<?php esc_html_e( 'Read More', 'rx-theme' ); ?>
</a>
</div>
</article>
<?php
}
/**
* Helper: create localized AJAX data.
*
* Use this in enqueue.php or functions.php.
*
* @return array
*/
function rx_load_more_localize_data() {
return array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'action' => RX_LOAD_MORE_ACTION,
'nonce' => wp_create_nonce( RX_LOAD_MORE_NONCE_ACTION ),
'nonceName' => RX_LOAD_MORE_NONCE_NAME,
'loadingText' => esc_html__( 'Loading...', 'rx-theme' ),
'loadMoreText' => esc_html__( 'Load More', 'rx-theme' ),
'noMoreText' => esc_html__( 'No More Posts', 'rx-theme' ),
'errorText' => esc_html__( 'Something went wrong. Please try again.', 'rx-theme' ),
);
}
/**
* Optional helper: print load more button.
*
* You can call this in archive.php, index.php, category.php, search.php.
*
* Example:
* rx_load_more_button();
*
* @param array $args Button args.
* @return void
*/
function rx_load_more_button( $args = array() ) {
global $wp_query;
$defaults = array(
'post_type' => get_post_type() ? get_post_type() : 'post',
'page' => max( 1, get_query_var( 'paged' ) ),
'posts_per_page' => get_option( 'posts_per_page' ),
'max_pages' => isset( $wp_query->max_num_pages ) ? absint( $wp_query->max_num_pages ) : 1,
'template' => 'card',
'button_text' => esc_html__( 'Load More', 'rx-theme' ),
'class' => 'rx-load-more-button',
);
$args = wp_parse_args( $args, $defaults );
if ( $args['max_pages'] <= 1 ) {
return;
}
?>
<div class="rx-load-more-wrap">
<button
type="button"
class="<?php echo esc_attr( $args['class'] ); ?>"
data-page="<?php echo esc_attr( $args['page'] ); ?>"
data-post-type="<?php echo esc_attr( $args['post_type'] ); ?>"
data-posts-per-page="<?php echo esc_attr( $args['posts_per_page'] ); ?>"
data-max-pages="<?php echo esc_attr( $args['max_pages'] ); ?>"
data-template="<?php echo esc_attr( $args['template'] ); ?>"
aria-label="<?php echo esc_attr( $args['button_text'] ); ?>"
>
<span class="rx-load-more-button__text">
<?php echo esc_html( $args['button_text'] ); ?>
</span>
</button>
</div>
<?php
}
Add this include in functions.php
/**
* AJAX Load More.
*/
require_once get_template_directory() . '/inc/ajax/load-more.php';
Add this in your inc/core/enqueue.php
<?php
/**
* Enqueue RX load more script.
*/
defined( 'ABSPATH' ) || exit;
function rx_enqueue_load_more_assets() {
wp_enqueue_script(
'rx-load-more',
get_template_directory_uri() . '/assets/js/load-more.js',
array(),
wp_get_theme()->get( 'Version' ),
true
);
if ( function_exists( 'rx_load_more_localize_data' ) ) {
wp_localize_script(
'rx-load-more',
'rxLoadMore',
rx_load_more_localize_data()
);
}
}
add_action( 'wp_enqueue_scripts', 'rx_enqueue_load_more_assets' );
Create assets/js/load-more.js
(function () {
'use strict';
const config = window.rxLoadMore || {};
if (!config.ajaxUrl || !config.action || !config.nonce) {
return;
}
const buttons = document.querySelectorAll('.rx-load-more-button');
if (!buttons.length) {
return;
}
buttons.forEach((button) => {
button.addEventListener('click', async function () {
if (button.classList.contains('is-loading')) {
return;
}
const wrapper = button.closest('.rx-load-more-wrap');
const postsContainer =
document.querySelector('[data-rx-posts-container]') ||
document.querySelector('.rx-posts-container') ||
document.querySelector('.site-main');
if (!postsContainer) {
return;
}
const currentPage = parseInt(button.dataset.page || '1', 10);
const maxPages = parseInt(button.dataset.maxPages || '1', 10);
const nextPage = currentPage + 1;
if (currentPage >= maxPages) {
button.disabled = true;
button.querySelector('.rx-load-more-button__text').textContent = config.noMoreText || 'No More Posts';
return;
}
const formData = new FormData();
formData.append('action', config.action);
formData.append(config.nonceName || 'nonce', config.nonce);
formData.append('page', currentPage);
formData.append('post_type', button.dataset.postType || 'post');
formData.append('posts_per_page', button.dataset.postsPerPage || '6');
formData.append('template', button.dataset.template || 'card');
if (button.dataset.category) {
formData.append('category', button.dataset.category);
}
if (button.dataset.tag) {
formData.append('tag', button.dataset.tag);
}
if (button.dataset.taxonomy && button.dataset.term) {
formData.append('taxonomy', button.dataset.taxonomy);
formData.append('term', button.dataset.term);
}
if (button.dataset.search) {
formData.append('search', button.dataset.search);
}
button.classList.add('is-loading');
button.disabled = true;
const text = button.querySelector('.rx-load-more-button__text');
if (text) {
text.textContent = config.loadingText || 'Loading...';
}
try {
const response = await fetch(config.ajaxUrl, {
method: 'POST',
credentials: 'same-origin',
body: formData
});
const result = await response.json();
if (!result || !result.success) {
throw new Error(result?.data?.message || config.errorText || 'Request failed.');
}
const data = result.data || {};
if (data.html) {
postsContainer.insertAdjacentHTML('beforeend', data.html);
}
button.dataset.page = String(data.page || nextPage);
if (!data.has_more) {
button.disabled = true;
button.classList.add('is-finished');
if (text) {
text.textContent = data.button_label || config.noMoreText || 'No More Posts';
}
if (wrapper) {
wrapper.classList.add('is-finished');
}
} else {
button.disabled = false;
if (text) {
text.textContent = data.button_label || config.loadMoreText || 'Load More';
}
}
document.dispatchEvent(
new CustomEvent('rxLoadMoreSuccess', {
detail: data
})
);
} catch (error) {
button.disabled = false;
if (text) {
text.textContent = config.loadMoreText || 'Load More';
}
document.dispatchEvent(
new CustomEvent('rxLoadMoreError', {
detail: {
error: error.message
}
})
);
console.error('[RX Load More]', error);
} finally {
button.classList.remove('is-loading');
}
});
});
})();
Example use in index.php, archive.php, or category.php
Your post wrapper should have this attribute:
<div class="rx-posts-container" data-rx-posts-container>
<?php
if ( have_posts() ) :
while ( have_posts() ) :
the_post();
get_template_part( 'template-parts/content', 'card' );
endwhile;
endif;
?>
</div>
<?php
if ( function_exists( 'rx_load_more_button' ) ) {
rx_load_more_button(
array(
'post_type' => 'post',
'posts_per_page' => get_option( 'posts_per_page' ),
'template' => 'card',
)
);
}
?>
Optional CSS
.rx-load-more-wrap {
text-align: center;
margin-top: 32px;
}
.rx-load-more-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
padding: 12px 24px;
border: 0;
border-radius: 999px;
cursor: pointer;
font-weight: 600;
}
.rx-load-more-button:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.rx-load-more-button.is-loading {
pointer-events: none;
}
.rx-load-more-card {
margin-bottom: 32px;
}
.rx-load-more-card__thumbnail {
display: block;
margin-bottom: 16px;
}
.rx-load-more-card__thumbnail img {
width: 100%;
height: auto;
display: block;
}
.rx-load-more-card__title {
margin: 0 0 8px;
}
.rx-load-more-card__meta {
font-size: 14px;
opacity: 0.75;
margin-bottom: 12px;
}
.rx-load-more-card__excerpt {
margin-bottom: 16px;
}
This is a strong production-style starting file for your RX theme. For best theme structure, keep the PHP endpoint in:
inc/ajax/load-more.php
Keep the frontend JavaScript in:
assets/js/load-more.js