load-more.php

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

Leave a Reply

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