“Pagination” gets used for four different things in WordPress, and they don’t share code, template tags, or even a query variable. Mixing them up is the most common reason a pagination feature “doesn’t work” — the fix people reach for belongs to a different mechanism entirely. Here’s the map, then each piece in detail.
- Content pagination — splitting one post or page’s body into multiple URLs with the
<!--nextpage-->tag. - Archive pagination — paging through a list of posts on a blog index, category, tag, author, or search page.
- Comment pagination — paging through a long comment thread on a single post.
- Custom-query pagination — paging a
WP_Queryyou built yourself, outside the main loop.
They render similarly — a row of page numbers, previous/next links — but each has its own template tag, its own query var, and its own way of quietly breaking.
1. Content pagination: splitting one post across pages
Useful for long, text-heavy content — a multi-step tutorial, a recipe, a reference list — where breaking it up genuinely helps readability. It’s worth avoiding purely to inflate ad impressions; that kind of artificial pagination is a UX problem today, not a growth tactic.
Insert the tag. In the block editor, search the block inserter for Page Break and drop it wherever the post should split — it inserts the tag for you. Working in the Code Editor view (or a Classic Editor Text tab) instead, add the raw tag directly:
<!--nextpage-->
This works in any well-coded theme without extra setup.
Make sure wp_link_pages() is in the template. The nextpage tag splits the content on its own — WordPress serves only the current page’s portion of it. What it won’t do by itself is give visitors a way to reach page 2, 3, and so on. That navigation comes from a template tag that has to be present in the loop:
<?php wp_link_pages(); ?>
Add it to single.php to paginate posts, or to page.php to do the same for static pages. If it’s missing, the tag isn’t broken — readers are just stuck on page one with no link to the rest.
wp_link_pages() also takes arguments for wrapping markup and link text:
wp_link_pages( array(
'before' => '<nav class="pagination pagination-content" aria-label="Page navigation">',
'after' => '</nav>',
) );
One thing worth knowing before you style it: core wraps each page number in its own class here — see Styling once, consistently below for why that matters and how to handle it.
2. Archive pagination: paging through a list of posts
This is the pagination most people mean when they say the word — the numbered links at the bottom of a blog index, category, or search page. It comes from the main query’s paged variable, and the template tag for it is paginate_links():
if ( have_posts() ) :
while ( have_posts() ) : the_post();
// ... the loop ...
endwhile;
echo paginate_links( array(
'prev_text' => '← Newer',
'next_text' => 'Older →',
) );
endif;
It automatically reads $wp_query for the current page and the total page count, so on the main query you don’t need to pass either one in. It also collapses long runs into an ellipsis once the page count gets large — controlled by the mid_size and end_size arguments if the defaults show too many or too few numbers around the current page.
If a numbered list is more than the archive needs, get_next_posts_link() / get_previous_posts_link() give a plainer two-link version without the ellipsis logic — useful for a simple “Older / Newer” pair.
Wrap it in a landmark with a label, the way you would any navigation region:
<nav class="pagination" aria-label="Posts navigation">
<?php echo paginate_links(); ?>
</nav>
Core already marks the current page for assistive tech — the active link gets aria-current="page" automatically. No need to add that by hand.
3. Comment pagination
Long comment threads paginate independently of the post content, and they’re off by default. Turn them on under Settings → Discussion → “Break comments into pages”, along with how many top-level comments count as one page and whether the last page shows first.
In comments.php, the template tag is paginate_comments_links(), guarded by a check that comment pagination is actually turned on and needed:
<?php if ( get_comment_pages_count() > 1 && get_option( 'page_comments' ) ) : ?>
<nav class="pagination" aria-label="Comments navigation">
<?php paginate_comments_links(); ?>
</nav>
<?php endif; ?>
It shares the same page-numbers / current / dots class convention as paginate_links(), so styling it costs nothing extra once archive pagination is already styled.
4. Custom-query pagination: the paged vs. page trap
The moment you build your own WP_Query — a related-posts block, a filtered grid, anything outside the main loop — pagination is where it breaks first, and almost always for the same reason: paged and page are two different query vars.
paged— which page of a list of posts (archive pagination, custom queries).page— which page of a single post’s content (the nextpage tag).
Reading the wrong one is a common bug: a custom query built with get_query_var( 'page' ) instead of get_query_var( 'paged' ) will paginate correctly on page 1, then silently stall on every page after it.
The safer fix, when the custom query is meant to replace the main loop on an archive, is not to run a second query at all — hook pre_get_posts and modify the main query in place, so WordPress keeps computing max_num_pages correctly for you:
function vc_filter_archive_query( $query ) {
if ( ! is_admin() && $query->is_main_query() && is_post_type_archive( 'project' ) ) {
$query->set( 'posts_per_page', 12 );
}
}
add_action( 'pre_get_posts', 'vc_filter_archive_query' );
When a genuinely separate query is unavoidable (a “related posts” block inside single content, say), pass paged in explicitly and don’t disable found-row counting, or pagination has nothing to count against:
$paged = get_query_var( 'paged' ) ? get_query_var( 'paged' ) : 1;
$related = new WP_Query( array(
'post_type' => 'project',
'posts_per_page' => 6,
'paged' => $paged,
// no_found_rows defaults to false — leave it, or max_num_pages is always 0.
) );
Styling once, consistently
Here’s a gotcha worth knowing before it costs you an hour: paginate_links() and wp_link_pages() render conceptually the same thing — a row of numbered links with a current-page state — but core gives them different class names. Archive and comment pagination get page-numbers / page-numbers.current / page-numbers.dots. Content pagination gets post-page-numbers / post-page-numbers.current, with no ellipsis class at all since it always lists every page.
Style only .page-numbers and a wp_link_pages() block will render with no styling whatsoever — not broken, just invisible-looking, plain underlined links in a page that otherwise doesn’t use them.
Two ways to handle it. Either extend your selectors to cover both class names:
.pagination .page-numbers,
.pagination .post-page-numbers {
font-size: 13px; padding: 10px 16px; border: 1px solid var(--rule);
}
.pagination .page-numbers.current,
.pagination .post-page-numbers.current {
background: var(--ink); color: var(--paper);
}
or normalize the markup at the source with wp_link_pages()‘s link_before / link_after arguments, wrapping each number in a matching page-numbers span. The first option is less code and doesn’t produce nested wrapper elements, so it’s the one to reach for by default.
SEO, accessibility, performance
Canonical URLs are handled for you. Each paginated page — content, archive, or comment — gets its own real URL and its own self-referencing canonical tag automatically; there’s no duplicate-content cleanup to do by hand.
rel="next" / rel="prev" are optional now, not required. They used to be a Google indexing signal for paginated series; Google said in 2019 it no longer uses them. They’re still valid HTML and still useful as a hint to other crawlers and some browser tooling, so there’s no harm including them, but skipping them costs nothing for SEO today.
Accessibility is mostly free if you use the core tags. Both paginate_links() and wp_link_pages() already add aria-current="page" to the active link. The part that’s on you: wrap the output in a labeled <nav> landmark (aria-label="Posts navigation", or similar) so screen reader users can jump straight to it instead of tabbing through the whole list.
Large archives make paginate_links() more expensive than it looks. By default WordPress runs SQL_CALC_FOUND_ROWS to know the total count for max_num_pages — fine at normal scale, a real cost on a very large, ungrouped table. If a query only needs a handful of items and pagination isn’t shown at all, set 'no_found_rows' => true on that query and skip the count entirely.
When not to paginate
Numbered pagination is the sane default: every page has a real, bookmarkable, crawlable URL, and the browser’s own back button and scroll position keep working the way people expect. Infinite scroll and “load more” trade that away — harder to link to a specific point, easy to strand the footer, and prone to hurting Core Web Vitals (layout shift, growing DOM, worse input latency) if the loaded content isn’t managed carefully.
The reasonable middle ground, if endless scrolling is genuinely wanted for UX reasons, is progressive enhancement: render real, numbered, paginated pages first, then use JavaScript to fetch and append the next page in place while updating the URL via the History API. Visitors without JS, and every crawler, still get working pagination; everyone else gets the smoother scroll.