Development
How to Fix WP_Query Pagination in a Custom WordPress Loop

Custom WP_Query pagination usually breaks because the current page is read from the wrong query variable, the custom query never receives paged, or pagination links use the main query's page count. Those mistakes select the wrong records or build the wrong navigation.
Resetting global post data solves a separate problem. It protects template tags that run after the secondary loop, but it cannot repair an incorrect page variable, query, or total.
The fix is to treat the loop and its navigation as one unit. They must share the same current page, page size, filters, and maximum page count.
A complete custom loop
This example belongs in a page template or another front-end template where a secondary loop is appropriate:
<?php
/**
* Returns the current page number for a custom front-end query.
*/
function ml_get_current_paged(): int
{
$paged = get_query_var('paged');
if (!$paged && is_front_page()) {
$paged = get_query_var('page');
}
return max(1, absint($paged));
}
$current_page = ml_get_current_paged();
$topic = isset($_GET['topic']) && is_string($_GET['topic'])
? sanitize_key(wp_unslash($_GET['topic']))
: '';
$query_arguments = [
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => 12,
'paged' => $current_page,
'ignore_sticky_posts' => true,
'orderby' => [
'date' => 'DESC',
'ID' => 'DESC',
],
];
if ($topic !== '') {
$query_arguments['category_name'] = $topic;
}
$articles = new WP_Query($query_arguments);
?>
<?php if ($articles->have_posts()) : ?>
<div class="article-grid">
<?php while ($articles->have_posts()) : ?>
<?php $articles->the_post(); ?>
<article <?php post_class('article-card'); ?>>
<h2 class="article-card__title">
<a href="<?php the_permalink(); ?>">
<?php the_title(); ?>
</a>
</h2>
<?php the_excerpt(); ?>
</article>
<?php endwhile; ?>
</div>
<?php
$pagination_arguments = $topic === '' ? [] : ['topic' => $topic];
$pagination = paginate_links(
[
'base' => str_replace(
999999999,
'%#%',
esc_url(get_pagenum_link(999999999))
),
'format' => '?paged=%#%',
'current' => $current_page,
'total' => (int) $articles->max_num_pages,
'mid_size' => 2,
'end_size' => 1,
'prev_text' => esc_html__('Previous', 'my-theme'),
'next_text' => esc_html__('Next', 'my-theme'),
'add_args' => $pagination_arguments,
'type' => 'list',
]
);
?>
<?php if ($pagination) : ?>
<nav class="pagination" aria-label="<?php esc_attr_e('Posts', 'my-theme'); ?>">
<?php echo wp_kses_post($pagination); ?>
</nav>
<?php endif; ?>
<?php else : ?>
<p><?php esc_html_e('No posts were found.', 'my-theme'); ?></p>
<?php endif; ?>
<?php wp_reset_postdata(); ?>
The official WP_Query reference documents paged, available order values, and the query properties used here. paginate_links() builds the links from the custom query's max_num_pages.
Why paged and page both appear
For most archives and custom secondary loops, the current page is available through:
$paged = max(1, get_query_var('paged'));
On a static front page, WordPress can use the page query variable instead. The helper checks that case explicitly. Do not blindly add the two values together. Choose the active one and normalize it to a positive integer.
If pagination always returns page one, inspect the request URL and both query variables:
<?php
error_log(
wp_json_encode(
[
'paged' => get_query_var('paged'),
'page' => get_query_var('page'),
'is_front_page' => is_front_page(),
]
)
);
Remove temporary logging after diagnosis, and do not log private request data.
Use the custom query's page count
The global $wp_query->max_num_pages describes the main request. A secondary query has its own total. Passing the wrong one can create too few links, links to empty pages, or no navigation at all.
Use:
'total' => (int) $articles->max_num_pages,
Keep posts_per_page explicit. If it must match the site's Reading setting, retrieve that setting deliberately with get_option('posts_per_page') so the relationship is visible.
Do not combine offset with ordinary pagination
The WP_Query documentation warns that offset overrides or breaks normal pagination. If you need to skip featured posts, exclude their IDs with post__not_in, adjust the query design, or calculate the complete offset and total behavior yourself.
For example, excluding a known featured post is clearer than skipping the first result on every page:
'post__not_in' => array_map('absint', $featured_post_ids),
Remember that a large exclusion list has its own query cost. Measure it with representative content.
Preserve filters in links
If the loop accepts a category or search filter, validate it and pass the same value to both WP_Query and the pagination links. The complete example maps topic to the query's category_name argument and passes it through add_args, while reusing the same base, format, current, and total values as the unfiltered navigation.
For example, selecting the performance topic and following page two should request a URL shaped like /articles/page/2/?topic=performance. The second request must still set category_name to performance, report page two as current, and derive total from that filtered $articles query. Add an integration test that creates posts in two categories and proves that the other category never appears on either page.
Sanitization is not authorization. If a custom query exposes private posts or account-owned records, enforce access in server-side query logic rather than trusting a URL parameter.
Reset the global post
Inside a secondary loop, $post and template tags such as the_title() refer to the secondary query's current record. wp_reset_postdata() restores the main query's current post after the loop.
Call it even when the template appears to work without it. A later sidebar, footer, structured-data block, or plugin callback can otherwise read the wrong post.
wp_reset_query() is broader and is usually associated with replacing the main query through query_posts(). Avoid query_posts() for this use case. A separate WP_Query plus wp_reset_postdata() keeps responsibilities clear.
Make the navigation accessible
paginate_links(['type' => 'list']) provides useful list semantics. Wrap it in a nav element with a descriptive accessible label. Keep “Previous” and “Next” text meaningful, and ensure the theme's focus styles remain visible.
WordPress adds current-page semantics to generated pagination markup. Test the final output with a keyboard and a screen reader because theme CSS can still hide focus, shrink tap targets, or reduce contrast.
Troubleshooting checklist
When a custom loop still will not paginate, verify:
- Pretty permalinks have been flushed after rewrite changes.
- The request URL actually contains the intended page segment or argument.
- The template reads
paged, orpageon a static front page. - The custom query receives that normalized value.
- Pagination uses the custom query's
max_num_pages. - Filters are identical on every page.
- No
offsetconflicts with the page calculation. - The requested page is not beyond the final page.
- Post data is reset after the secondary loop.
- A cache is not serving the first page for every URL.
The central rule is simple: the custom query owns its pagination state. Read the correct request value, pass it into that query, build links from that query's total, and restore global state when the loop finishes.