DEV Community

Fran Agulto
Fran Agulto

Posted on

Pagination in Headless WordPress with WPGraphQL, Apollo, & Next.js

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:

Image description

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:

Image description

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:

Image description

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
        }
      }
    }
  }
Enter fullscreen mode Exit fullscreen mode

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>
      )}
    </>
  );
}

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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>;
  }
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
      )}
    </>
  );
}

Enter fullscreen mode Exit fullscreen mode

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:

Image description

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!

Oldest comments (1)

Collapse
 
salomep profile image
salome

Thanks for this wonderful article. I have a question: When you use useQuery to show posts, it's not SSR anymore, right? If we want to ensure the first load is server-side rendered, how should we do it