WordPress REST API Pagination: Handling Large Datasets Efficiently

A site with 2,000 blog posts and a frontend that tries to fetch them all at once is a site that’s about to have a bad time. The WordPress REST API handles pagination out of the box, but the defaults aren’t always enough — especially when you’re building a headless frontend that needs smooth infinite scroll or efficient data loading.
Default Pagination Basics
The WP REST API uses offset-based pagination with two query parameters: page and per_page. The maximum per_page value is 100 (WordPress enforces this server-side). Every paginated response includes two headers you should always read:
X-WP-Total— total number of items across all pagesX-WP-TotalPages— total number of pages
// Fetch page 2 with 20 items per page
const res = await fetch(
"https://wp.example.com/wp-json/wp/v2/posts?page=2&per_page=20"
);
const posts = await res.json();
const totalPosts = parseInt(res.headers.get("X-WP-Total") || "0");
const totalPages = parseInt(res.headers.get("X-WP-TotalPages") || "0");
console.log(`Page 2 of ${totalPages} (${totalPosts} total posts)`);
The Problem With Offset Pagination
Offset pagination has a well-known performance issue: the database still has to scan through all the skipped rows. Page 1 is fast. Page 50 of a 10,000-post site? The database is scanning 1,000 rows just to throw them away. It also breaks when new content gets published — items shift between pages, and users see duplicates or miss posts entirely.
Cursor-Based Pagination for Custom Endpoints
For custom REST API endpoints where you control the implementation, cursor-based pagination is significantly better. Instead of “give me page 5,” you say “give me the next 20 items after this specific post.” No offset scanning, no shifting results.
// In your custom REST API endpoint registration
register_rest_route('myapi/v1', '/posts', [
'methods' => 'GET',
'callback' => 'myapi_get_posts_cursor',
'args' => [
'after' => [
'type' => 'integer',
'sanitize_callback' => 'absint',
],
'per_page' => [
'type' => 'integer',
'default' => 20,
'maximum' => 100,
],
],
]);
function myapi_get_posts_cursor($request) {
$per_page = $request->get_param('per_page');
$after = $request->get_param('after');
$args = [
'post_type' => 'post',
'posts_per_page' => $per_page + 1, // Fetch one extra to check if more exist
'orderby' => 'ID',
'order' => 'DESC',
];
if ($after) {
$args['where'] = "ID < $after";
// Or use date-based cursor for chronological ordering
}
$query = new WP_Query($args);
$posts = $query->posts;
$has_more = count($posts) > $per_page;
if ($has_more) {
array_pop($posts); // Remove the extra item
}
$last_id = !empty($posts) ? end($posts)->ID : null;
return new WP_REST_Response([
'posts' => array_map('myapi_format_post', $posts),
'next_cursor' => $has_more ? $last_id : null,
'has_more' => $has_more,
]);
}
Frontend: Infinite Scroll Implementation
On the Next.js side, I use an Intersection Observer to trigger loading the next page when the user scrolls near the bottom. Here’s a clean implementation with cursor-based pagination:
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
interface Post {
id: number;
title: string;
excerpt: string;
}
export function PostList() {
const [posts, setPosts] = useState<Post[]>([]);
const [cursor, setCursor] = useState<number | null>(null);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const sentinelRef = useRef<HTMLDivElement>(null);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
const params = new URLSearchParams({ per_page: "20" });
if (cursor) params.set("after", String(cursor));
const res = await fetch(`/api/posts?${params}`);
const data = await res.json();
setPosts((prev) => [...prev, ...data.posts]);
setCursor(data.next_cursor);
setHasMore(data.has_more);
setLoading(false);
}, [cursor, hasMore, loading]);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) loadMore();
},
{ rootMargin: "200px" }
);
if (sentinelRef.current) observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [loadMore]);
return (
<div>
{posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
{loading && <p>Loading...</p>}
<div ref={sentinelRef} />
</div>
);
}
Optimizing the WordPress Side
A few server-side tweaks make a big difference:
- Use
_fieldsparameter — request only the fields you need:?_fields=id,title,excerpt,slug,date. This skips expensive serialization of content, embedded media, and author data. - Add database indexes — if you’re doing cursor pagination on custom fields, make sure those columns are indexed.
- Cache aggressively — use transients or an object cache (Redis) for frequently-requested pages. The first page of blog posts gets hit constantly.
- Disable
found_rows— if you don’t need total counts (cursor pagination doesn’t), set'no_found_rows' => truein your WP_Query. This skips aSQL_CALC_FOUND_ROWSquery that gets expensive on large tables.
Pagination seems simple until you have enough data for it to matter. Get the foundation right — cursor-based for custom endpoints, proper caching, selective field loading — and you won’t have to rethink it when your content library grows.
Written by
Adrian Saycon
A developer with a passion for emerging technologies, Adrian Saycon focuses on transforming the latest tech trends into great, functional products.


