Pagination in Headless WordPress with WPGraphQL, Apollo, & Next.js
Fran Agulto
Posted on February 7, 2023
In this article, I will discuss how WPGraphQL does cursor-based pagination in headless WordPress along with using Next.js and Apollo GraphQL for my client.
Pagination 📃
Pagination on the web is defined as a process of breaking or separating the content of a website into different pages. The user can use links such as “next,” “previous” and page numbers to navigate between pages and display sets of data when there is too much data to display all of the records at one time.
Cursor-based Pagination in WPGraphQL
Out of the box, WPGraphQL ships with what is called cursor-based pagination. This method loads the initial set of records and provides a cursor which is the reference point for the next request to utilize it and make the request for the next batch of records.
Traditional WordPress uses what is called offset-based pagination. This method groups post within the page numbers, essentially putting parameters within the client requests with a specific limit or number of results and then offset, the number of records that need to be skipped.
I won’t go too deep into the pros and cons of each in this article. Please check out Jason Bahl’s article here if you want to get a deeper dive into each method.
WPGraphQL Queries with Cursor-based pagination 🐘
Let’s navigate to the WP Admin and GraphiQL IDE now to discuss how to query WPGraphQL with cursor-based pagination. Below is a screenshot of my initial query with the fields and arguments that come out of the box for pagination with WPGraphQL:
In this initial query, I am asking for a list of my posts, with the arguments of grabbing the first 3 posts and then after that "null" or starting from the beginning of the list.
What if instead of starting at the beginning of the list, I want to request the list of posts after the first 3? This is where the cursor field and pageInfo
type come in.
The pageInfo
type has 2 fields called hasNextPage
and endCursor
. The hasNextPage
field allows you to find out if you have more pages with your posts on that data set. The endCursor
field is a unique identifier string that represents the last post of the data set you are requesting. It points to that last post on the request as shown here:
Essentially, I can now ask for every post before or after that unique identifier string that endCursor
gives me instead of starting from the beginning. In this case, the post tied to that unique ID is "
Obi-Wan". When I grab the unique ID string and use it with the after
argument instead of null
, the query will start from that post and give me all the posts after it:
This opens up a realm of other possibilities. You can just swap out the end cursor and fire off queries to get the next subset of results after the last one from that cursor. You can do it bi-directionally as well where you can get the last 3 before the end cursor, paginating both forward and backward.
There are performance gains in this method. Because it uses the unique ID to locate the record and then counts forward or backward from that ID instead of loading every dataset in the case of offset pagination, it requires fewer resources in loading batches of data.
Let's re-write our query so that we can dynamically pass in the argument in the fields instead of hard coding the string, like so:
query getPosts($first: Int!, $after: String) {
posts(first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
id
databaseId
title
slug
}
}
}
}
We are accepting input arguments first
which is an integer and after
which is a string. In our front-end app, we can pass in the first 3 then when the user hits the load more button, our app then grabs the end cursor and will pass in different query variables to get the next set of data results on whichever end cursor string was tied to that post.
This query is now ready to use on our first pagination example called Load More which will be used in our Next.js front-end using the Apollo client.
Load-More in Next.js and Apollo
In my Next.js application, I have a file called LoadMorePost.js
which is a component that lives in my component folder:
import { useQuery, gql } from "@apollo/client";
import Link from "next/link";
const GET_POSTS = gql`
query getPosts($first: Int!, $after: String) {
posts(first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
id
databaseId
title
slug
}
}
}
}
`;
const BATCH_SIZE = 5;
export default function LoadMorePost() {
const { data, loading, error, fetchMore } = useQuery(GET_POSTS, {
variables: { first: BATCH_SIZE, after: null },
notifyOnNetworkStatusChange: true,
});
if (error) {
return <p>Sorry, an error happened. Reload Please</p>;
}
if (!data && loading) {
return <p>Loading...</p>;
}
if (!data?.posts.edges.length) {
return <p>no posts have been published</p>;
}
const posts = data.posts.edges.map((edge) => edge.node);
const haveMorePosts = Boolean(data?.posts?.pageInfo?.hasNextPage);
return (
<>
<ul style={{ padding: "0" }}>
{posts.map((post) => {
const { databaseId, title, slug } = post;
return (
<li
key={databaseId}
style={{
border: "2px solid #ededed",
borderRadius: "10px",
padding: "2rem",
listStyle: "none",
marginBottom: "1rem",
}}
>
<Link href={`/blog/${slug}`}>{title}</Link>
</li>
);
})}
</ul>
{haveMorePosts ? (
<form
method="post"
onSubmit={(event) => {
event.preventDefault();
fetchMore({ variables: { after: data.posts.pageInfo.endCursor } });
}}
>
<button type="submit" disabled={loading}>
{loading ? "Loading..." : "Load more"}
</button>
</form>
) : (
<p>✅ All posts loaded.</p>
)}
</>
);
}
Let’s break this file down into chunks. At the top of the file, I am importing the useQuery
hook and gql
provided by the Apollo client that I am using as well as next/link
from Next.js. We will need these imports in this file.
The next thing you see is the query we created in GraphiQL back in our WordPress admin with the assistance of WPGraphQL which will allow us to fire off requests to WPGraphQL and use cursor-based pagination.
The following line shows the number of posts I want to grab in a constant in BATCH_SIZE
. When the user hits the load more button, it will populate 5 posts in each load.
const GET_POSTS = gql`
query getPosts($first: Int!, $after: String) {
posts(first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
id
databaseId
title
slug
}
}
}
}
`;
const BATCH_SIZE = 5;
After that, I have a default components function called LoadMorePost
. In this function, I am making use of the useQuery
hook in Apollo to pass in my query called GET_POSTS
from the top of the file. Next, I have variables that I pass in, which was the batch size I defined to be 5 and after null
or start from the beginning. This function gets fired off each time the user clicks the “load more” button.
Following that, I have some if
conditionals that invoke execution of possible states if an “error,” “loading,” or if we have no posts and the request has finished then we have no more posts published. If those checks have all passed, it means we have posts to be displayed.
export default function LoadMorePost() {
const { data, loading, error, fetchMore } = useQuery(GET_POSTS, {
variables: { first: BATCH_SIZE, after: null },
notifyOnNetworkStatusChange: true,
});
if (error) {
return <p>Sorry, an error happened. Reload Please</p>;
}
if (!data && loading) {
return <p>Loading...</p>;
}
if (!data?.posts.edges.length) {
return <p>no posts have been published</p>;
}
There are 2 variables that get set next. The first variable is posts
which is taking the data that Apollo gives us back and drilling down into it with the posts and their nested data. The second variable is haveMorePosts
which checks if we have more posts to load but if there are no more posts we will have to execute something else.
const posts = data.posts.edges.map((edge) => edge.node);
const haveMorePosts = Boolean(data?.posts?.pageInfo?.hasNextPage);
So now we can display our posts with a return
statement with some data drilling within the levels of nesting that comes from the query.
Focusing now on the return
statement, we have a <ul>
tag. Within that tag, we are mapping over posts and returning a single post with a databaseId
, title
, and its slug
. For each of those, we are displaying a list item with a <li>
tag. This list item will have a title that has a link to the actual individual blog post’s page.
<ul style={{ padding: "0" }}>
{posts.map((post) => {
const { databaseId, title, slug } = post;
return (
<li
key={databaseId}
style={{
border: "2px solid #ededed",
borderRadius: "10px",
padding: "2rem",
listStyle: "none",
marginBottom: "1rem",
}}
>
<Link href={`/blog/${slug}`}>{title}</Link>
</li>
);
})}
</ul>
Lastly, we have to add a “load more” button. This button when clicked will load the next batch of posts from the cursor’s point. In order to do this, we take our haveMorePosts
boolean and if we do have more, we will display a form with a button inside of it. When that button is clicked, we have a onSubmit
handler that calls the fetchMore
function in Apollo and passes in the variable called after
that grabs the current end cursor, which is the unique ID that represents the last post in the data set to grab the next 5 after that end cursor.
{haveMorePosts ? (
<form
method="post"
onSubmit={(event) => {
event.preventDefault();
fetchMore({ variables: { after: data.posts.pageInfo.endCursor } });
}}
>
<button type="submit" disabled={loading}>
{loading ? "Loading..." : "Load more"}
</button>
</form>
) : (
<p>✅ All posts loaded.</p>
)}
</>
);
}
This is done and I have placed this component in pages/load-more.js
within my Next.js app to give it a route and a page.
Let’s see how this all looks in action:
Conclusion 🚀
Pagination is an important part and very common in modern websites and apps. In this article, I hope you took away a better understanding of how to paginate in headless WordPress with the best-in-class frameworks and tools: WPGraphQL, Next.js, and the Apollo Client.
As always, stoked to hear your feedback and any questions you might have on headless WordPress! Hit us up in our discord!
Posted on February 7, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.