2026年11月13日

2026年11月13日

WordPress Pagination: paginate_links, Custom Queries and Load More

This article is a localized edition of the Japanese original. Read the Japanese original

Pagination in WordPress is what prints links such as "1 2 3 … Next" when a list of posts is split across several pages. In a classic theme you output it with the_posts_pagination() or paginate_links(); in a block theme you use the Pagination block inside the Query Loop block.

The code in this article was checked on WordPress 7.1 and PHP 8.3 as of November 2026, including the links and URLs it produces. Put the PHP code in a child theme's functions.php or in your own plugin.

Change the number of posts per page

The number of posts on each page is set under Settings → Reading → "Number of recent posts shown on archive pages". The number of pages follows from this value and the number of posts.

To use a different number for each kind of page, change posts_per_page on the main query in pre_get_posts.

<?php
// Show 12 posts on category pages and 20 on search results
add_action( 'pre_get_posts', function ( $query ) {
	if ( is_admin() || ! $query->is_main_query() ) {
		return;
	}
	if ( $query->is_category() ) {
		$query->set( 'posts_per_page', 12 );
	}
	if ( $query->is_search() ) {
		$query->set( 'posts_per_page', 20 );
	}
} );

Without the is_main_query() check, the count of other queries, such as those in the sidebar, changes too.

Add page numbers to post lists (the main query)

The blog home, category, tag and search result lists are all displayed by the "main query" that WordPress builds from the URL. In a classic theme template (index.php, archive.php and so on), add the following after the loop.

<?php
the_posts_pagination(
	array(
		'mid_size'           => 2,             // How many page numbers to show on each side of the current page
		'prev_text'          => 'Previous page',
		'next_text'          => 'Next page',
		'screen_reader_text' => 'Posts pagination', // Used for the heading and aria-label
	)
);

The output is a <nav class="navigation pagination"> element containing links with the page-numbers class. The current page becomes <span aria-current="page" class="page-numbers current">, so target these classes in your CSS.

  • Nothing is printed when there is only one page.
  • the_posts_pagination() outputs an HTML string, so passing 'type' => 'array' is ignored and the normal markup is printed.
  • For just "Previous" and "Next" links without numbers, use the_posts_navigation().

On a single post, "previous post" and "next post" links come from the_post_navigation(). %title is replaced with the post title.

<?php
the_post_navigation(
	array(
		'prev_text' => '<span class="nav-label">Previous post</span> %title',
		'next_text' => '<span class="nav-label">Next post</span> %title',
	)
);

Build your own markup with paginate_links()

Pass 'type' => 'array' to paginate_links() and you get the links back as an array of strings, one per link. You can wrap them in any tags to match your design. With 'type' => 'list' it returns a <ul class="page-numbers"> list.

The function below returns the page links of the given WP_Query as a <ul>. It works for both the main query and custom queries.

<?php
/**
 * Return the page links of a WP_Query as a ul
 */
function myprefix_pagination( WP_Query $query ) {
	$total = (int) $query->max_num_pages;
	if ( $total <= 1 ) {
		return '';
	}

	$links = paginate_links(
		array(
			'current'   => max( 1, (int) $query->get( 'paged' ) ),
			'total'     => $total,
			'mid_size'  => 2, // Numbers on each side of the current page
			'end_size'  => 1, // Numbers at the start and end
			'prev_text' => 'Previous',
			'next_text' => 'Next',
			'type'      => 'array',
		)
	);

	if ( empty( $links ) ) {
		return '';
	}

	$html = '<nav class="pagination" aria-label="Pagination"><ul>';
	foreach ( $links as $link ) {
		$html .= '<li>' . $link . '</li>';
	}
	$html .= '</ul></nav>';

	return $html;
}

In a template, call it like echo myprefix_pagination( $GLOBALS['wp_query'] );.

If you leave out base and format, paginate_links() builds links of the form /page/2/ from the current URL. In our test, on a regular page (for example /news-list/) and on a static front page alike, the link to page 1 pointed to the original URL rather than /page/1/. On sites whose permalink setting is "Plain" (URLs like ?p=123), the links take the form ?paged=2.

Add pagination to a custom query (WP_Query)

When you list posts with new WP_Query(), for example in a page template, pass the current page number in paged. Without it the links still appear, but every page shows the contents of page 1.

The catch is that the query variable holding the page number differs by location. This is what we observed:

Page being displayed Example URL Variable holding the number
A regular page /news-list/page/2/ paged
Static front page (a page set as the homepage under Reading) /page/2/ page
Post lists and archives /category/news/page/2/ paged

A helper that checks both works anywhere.

<?php
/**
 * Return the current page number (on a static front page it is in 'page')
 */
function myprefix_get_paged() {
	$paged = (int) get_query_var( 'paged' );
	if ( 0 === $paged ) {
		$paged = (int) get_query_var( 'page' );
	}
	return max( 1, $paged );
}

Here is how to use it in a page template.

<?php
$news = new WP_Query(
	array(
		'post_type'      => 'post',
		'category_name'  => 'news', // Category slug
		'posts_per_page' => 5,
		'paged'          => myprefix_get_paged(),
	)
);

if ( $news->have_posts() ) {
	echo '<ul>';
	while ( $news->have_posts() ) {
		$news->the_post();
		printf(
			'<li><a href="%s">%s</a></li>',
			esc_url( get_permalink() ),
			esc_html( get_the_title() )
		);
	}
	echo '</ul>';

	echo myprefix_pagination( $news );
}

wp_reset_postdata();

the_posts_pagination() only looks at the page count of the main query (the global $wp_query), so it does not work for custom queries. Pass the query, which has max_num_pages, and print the links with paginate_links() as shown above.

Custom queries on archives lead to 404s

If an archive template, such as a category template, runs its own custom query with a different number of posts per page, the later pages can return 404. WordPress resolves /page/N/ in the URL against the main query, so any number beyond the main query's page count is treated as a page that does not exist. In our test (24 posts, 10 per page, 3 pages), /page/3/ loaded and /page/4/ returned 404.

To change the count or conditions of an archive, do not create a custom query. Modify the main query with pre_get_posts as shown earlier and use the_posts_pagination(). The page count and the URL check then match.

Load more posts with a button (Ajax)

Here a button appends the next page of posts to the bottom of the current page. It uses admin-ajax.php.

PHP (functions.php)

<?php
// Load the script and pass the Ajax URL and nonce (posts page only)
add_action( 'wp_enqueue_scripts', function () {
	if ( ! is_home() ) {
		return;
	}
	wp_enqueue_script(
		'myprefix-load-more',
		get_theme_file_uri( 'js/load-more.js' ),
		array(),
		'1.0',
		array( 'strategy' => 'defer' )
	);
	wp_localize_script(
		'myprefix-load-more',
		'myprefixLoadMore',
		array(
			'ajaxUrl' => admin_url( 'admin-ajax.php' ),
			'nonce'   => wp_create_nonce( 'myprefix_load_more' ),
		)
	);
} );

// Accept requests from logged-in (wp_ajax_) and logged-out (wp_ajax_nopriv_) users
add_action( 'wp_ajax_myprefix_load_more', 'myprefix_load_more' );
add_action( 'wp_ajax_nopriv_myprefix_load_more', 'myprefix_load_more' );

function myprefix_load_more() {
	check_ajax_referer( 'myprefix_load_more', 'nonce' );

	$page  = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1;
	$query = new WP_Query(
		array(
			'post_type'      => 'post',
			'post_status'    => 'publish',
			'posts_per_page' => (int) get_option( 'posts_per_page' ),
			'paged'          => max( 1, $page ),
		)
	);

	ob_start();
	while ( $query->have_posts() ) {
		$query->the_post();
		printf(
			'<article><h2><a href="%s">%s</a></h2></article>',
			esc_url( get_permalink() ),
			esc_html( get_the_title() )
		);
	}
	wp_reset_postdata();

	wp_send_json_success(
		array(
			'html'    => ob_get_clean(),
			'hasMore' => $page < (int) $query->max_num_pages,
		)
	);
}

In our test on a site with 24 posts and 10 per page, sending page=2 returned 10 posts and hasMore: true, and page=3 returned the remaining 4 posts and hasMore: false. With a wrong nonce, check_ajax_referer() returns -1 (HTTP 403) and stops.

To match your theme's post cards, load the theme's template part with get_template_part() instead of printf().

HTML in the template

<div id="post-list">
	<!-- Output the first page of posts with the usual loop -->
</div>
<button type="button" id="load-more">Load more</button>

JavaScript (js/load-more.js in the theme)

document.addEventListener('DOMContentLoaded', function () {
	const button = document.getElementById('load-more');
	const list = document.getElementById('post-list');
	if (!button || !list || typeof myprefixLoadMore === 'undefined') {
		return;
	}

	let nextPage = 2; // Start at 2 because page 1 is already displayed

	button.addEventListener('click', async function () {
		button.disabled = true;
		button.textContent = 'Loading…';

		const body = new FormData();
		body.append('action', 'myprefix_load_more');
		body.append('nonce', myprefixLoadMore.nonce);
		body.append('page', nextPage);

		try {
			const response = await fetch(myprefixLoadMore.ajaxUrl, {
				method: 'POST',
				body: body,
				credentials: 'same-origin',
			});
			const json = await response.json();
			if (!json.success) {
				throw new Error('request failed');
			}
			list.insertAdjacentHTML('beforeend', json.data.html);
			nextPage++;
			if (json.data.hasMore) {
				button.disabled = false;
				button.textContent = 'Load more';
			} else {
				button.remove();
			}
		} catch (error) {
			button.disabled = false;
			button.textContent = 'Could not load. Try again';
		}
	});
});

Things to watch with Ajax loading

  • On sites with page caching, the cached HTML keeps an old nonce, and after a while the request returns -1 and nothing loads. By default a nonce is valid for up to 24 hours. Keep the cache lifetime shorter than that, or, since this handler only returns published posts, you may decide to drop the nonce check.
  • Posts added by the button have no URL of their own. For search engines and environments without JavaScript, it is safer to keep the regular page links (/page/2/ and so on) as well.

Block themes

In block themes such as Twenty Twenty-Five, you build lists in the Site Editor instead of PHP templates. Place a Pagination block inside the Query Loop block and it shows Previous, page numbers and Next. When the Query Loop's "Query type" is "Default" (it uses the page's main query), the number of posts follows the Reading setting and pre_get_posts. With "Custom", the block's own settings decide the count.

Related articles

Questions about WordPress? The publisher of this site is based in Japan and replies in Japanese. Contact (Japanese)

お気軽にご相談ください

お見積りへ お問い合わせへ
▲