Skip to main content
Adzbyte
DevelopmentPerformanceWordPress

WordPress REST API Pagination: Handling Large Datasets Efficiently

Adrian Saycon
Adrian Saycon
March 22, 20264 min read
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 pages
  • X-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 _fields parameter — 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' => true in your WP_Query. This skips a SQL_CALC_FOUND_ROWS query 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.

Adrian Saycon

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.

Discussion (0)

Sign in to join the discussion

No comments yet. Be the first to share your thoughts.