Paginating your Contentful blog posts in Next.js with the GraphQL API

In this post, we’re going to build a set of article list pages that display a number of blog post summaries per page — fetched from the Contentful GraphQL API at build time. We’ll also include navigation to next and previous pages. What’s great about this approach is that it requires no client-side state. All article-list pages are pre-rendered into static HTML at build time. This requires a lot less code than you might think!

The benefits of Static Site Generation

Next.js is a powerful framework that offers Static Site Generation (SSG) for React applications. Static Site Generation is where your website pages are pre-rendered as static files using data fetched at build time (on the server), rather than running JavaScript to build the page in the browser (on the client) or on the server at the time someone visits your website (run time).

Some of the benefits of SSG:

  • Speed. Entire pages are loaded at first request rather than having to wait for client-side requests to fetch the data required.

  • Accessibility. Pages load without JavaScript.

  • Convenience. Host the files on your static hoster of choice (Netlify, Vercel or even good old GitHub pages) and call it a day!

  • It’s scalable, fast and secure.

Here’s how it looks in a complete Next.js starter. Click here to view a live demo of the code referenced in this post.

Screenshot of the blog index

To build the article list pagination, we’re going to harness the power of Static Site Generation provided by Next.js via the following two asynchronous functions:

  • getStaticProps: Fetch data at build time

  • getStaticPaths: Specify dynamic routes to pre-render pages based on data

If you’re new to Next.js, check out the documentation on Static Generation here.

Getting set up

I created a Next.js + Contentful blog starter repository that contains the completed code for statically generated article list pages described in this post. If you’d like to explore the code before getting started with the tutorial, you can fork the repository on GitHub here.

We’re going to create a fresh Next.js application and build up the functionality to understand how it all fits together.

For the purposes of this tutorial, you don’t need a Contentful account or any of your own blog posts. We’re going to connect to an example Contentful space that contains all of the data we need to build the article list pages and pagination. That being said, if you have an existing Contentful account and blog posts, you can connect your new Next.js application to your Contentful space with your own space ID and Contentful Delivery API access token. Just be sure to use the correct content type fields in your GraphQL queries if they are different to the example.

To spin up a new Next.js application, run the following command in your terminal:

npx create-next-app nextjs-contentful-pagination-tutorial

This command creates a new directory that includes all code to get started. This is what you should see after you run the command in your terminal window. (I’ve truncated the output a little with ‘...’ but what you’re looking for is ✨ Done!)

Screenshot of terminal output

Navigate to the root of your project directory to view the files created for you.

cd nextjs-contentful-pagination-tutorial
ls -la

If this is what you see, you’re good to go!

Screenshot of is la output

You now have a fresh Next.js application with all dependencies installed. But what data are we going to use to build the article list pages?

Retrieving example data

I created an example Contentful space that provides the data for the Next.js Contentful Blog Starter. It contains the content model we need and three blog posts so we can build out the pagination.

In the root of your project directory, create an .env.local file.

touch .env.local

Copy and paste the following into the .env.local file:

CONTENTFUL_SPACE_ID=84zl5qdw0ore
CONTENTFUL_ACCESS_TOKEN=_9I7fuuLbV9FUV1p596lpDGkfLs9icTP2DZA5KUbFjA

These credentials will connect the application to the example Contentful space to provide you with some data to build out the functionality.

We will use the following fields on the blogPost content type in our GraphQL queries to build the paginated article list:

  • Date (date & time)

  • Title (short text)

  • Slug (short text)

  • Tags (short text, list)

  • Excerpt (long text, presented in a markdown editor)

Screenshot of basic content type fields

You’re good to go if you have:

  • a fresh Next.js application

  • an .env.local file with the example credentials provided above

To run the application, navigate to the root of your project directory and run:

npm run dev

You’ll need to stop and start your development server each time you add a new file to the application.

So, we’ve got a Next.js application and credentials that we can use to connect us to a Contentful space! What files do we need in our application to implement a paginated blog?

Building the routes

We’re going to pre-render the following routes at build time, which will call out to the Contentful GraphQL API to get the data for each article list page:

  • /blog

  • /blog/page/2

  • /blog/page/3

  • etc

In the pages directory, create a new directory and name it blog. Add a file called index.js — this will be the /blog route.

cd my-blog/pages
mkdir blog 
cd blog
touch index.js

Next, inside the blog directory, create a new directory and name it page. Create a new file inside that directory, and name it [page].js — this will be our dynamic route, which will build the routes /blog/page/{pageNumber}. Read more about dynamic routes on the Next.js docs.

cd my-blog/pages/blog
mkdir page
cd page
touch [page].js

Here’s what your file and folder structure should look like:

Screenshot of the folder structure

That's all it takes to set up the routes /blog/ and /blog/page/{pageNumber}, but they're not doing anything yet. Let's get some data from Contentful.

Setting up the calls to the Contentful GraphQL API

To fill the pages with data, we need to make API calls. I prefer to define API calls in a dedicated file, so that they can be reused easily across the application. In this example, I created a ContentfulApi.js class, which can be found in the utils directory of the starter repository. We need to make two requests to the API to build our article list pages. 

Create a utils directory at the root of your project, and create a new file named ContentfulApi.js

Screenshot of Contentful api file

Before we start building the needed GraphQL queries, let’s set up an asynchronous call to the Contentful GraphQL API that takes in a string parameter named query. We’ll use this twice later to request data from Contentful.

If you’d like to learn more about GraphQL, check out Stefan Judis’s free GraphQL course on YouTube.

To explore the GraphQL queries in this post using the Contentful GraphiQL playground, navigate to the following URL and paste any of the queries below into the explorer (without the const and =). The space ID and access token in the URL will connect you to the same Contentful space that you connected to via the .env.local file.

https://graphql.contentful.com/content/v1/spaces/84zl5qdw0ore/explore?access_token=_9I7fuuLbV9FUV1p596lpDGkfLs9icTP2DZA5KUbFjA

Add the following code to /utils/ContentfulApi.js

// /utils/ContentfulApi.js

export default class ContentfulApi {

  static async callContentful(query) {
    const fetchUrl = `https://graphql.contentful.com/content/v1/spaces/${process.env.CONTENTFUL_SPACE_ID}`;

    const fetchOptions = {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.CONTENTFUL_ACCESS_TOKEN}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ query }),
    };

    try {
      const data = await fetch(fetchUrl, fetchOptions).then((response) =>
        response.json(),
      );
      return data;
    } catch (error) {
      throw new Error("Could not fetch data from Contentful!");
    }
  }
}

We’ve got our API call set up! Now, let’s fetch some data.

Querying the total number of posts

In order to calculate how many dynamic page routes we need to build and statically generate on /blog/page/[page].js, we need to work out how many blog posts we have, and divide that by the number of posts we want to show on each page.

numberOfPages = totalNumberOfPosts / howManyPostsToDisplayOnEachPage

For this, it’s useful to define how many posts you want to show on each page in a global variable or configuration object. We’ll need to use it in a few different places.

For that reason, the Next.js + Contentful blog starter contains a Config.js file in the utils directory. We’ll use the exported Config object in our API calls.

Feel free to skip this step and use a hard-coded number if you’re just exploring.

// /utils/Config.js

export const Config = {
  //...
  pagination: {
    pageSize: 2,
  },
};

In the same ContentfulApi class, let’s create a new asynchronous method that will query and return the total blog number of blog posts.

// /utils/ContentfulApi.js

export default class ContentfulApi {

  static async callContentful(query) { /* GQL call described above */ }

  static async getTotalPostsNumber() {
    // Build the query
    const query = `
      {
        blogPostCollection {
          total
        }
      }
    `;

    // Call out to the API
    const response = await this.callContentful(query);
    const totalPosts = response.data.blogPostCollection.total
      ? response.data.blogPostCollection.total
      : 0;

    return totalPosts;
  }
}

We’ve successfully retrieved our total number of blog posts. What’s next?

Querying the post summaries by page number

Let’s create a final asynchronous method that requests the number of blog post summaries we defined in Config.pagination.pageSize, by page number.

We also request the total number of blog posts in this query. We’ll need this later, and it saves us having to make two API calls when generating the /blog route.

Here’s the code. 

// /utils/ContentfulApi.js

export default class ContentfulApi {

  static async callContentful(query) { /* GQL call described above */ }

  static async getTotalPostsNumber() { /* method described above */ }

  static async getPaginatedPostSummaries(page) {
    const skipMultiplier = page === 1 ? 0 : page - 1;
    const skip =
      skipMultiplier > 0 ? Config.pagination.pageSize * skipMultiplier : 0;

    const query = `{
        blogPostCollection(limit: ${Config.pagination.pageSize}, skip: ${skip}, order: date_DESC) {
          total
          items {
            sys {
              id
            }
            date
            title
            slug
            excerpt
            tags
          }
        }
      }`;

    // Call out to the API
    const response = await this.callContentful(query);

    const paginatedPostSummaries = response.data.blogPostCollection
      ? response.data.blogPostCollection
      : { total: 0, items: [] };

    return paginatedPostSummaries;
  }
 }

Observe that we are querying for the five fields referenced at the top of this post: date, title, slug, tags and excerpt — plus the sys.id. This will be useful when rendering our data to the DOM.

The skip parameter in the GraphQL query is what does all the magic for us here. We calculate the skip parameter for the query based on the incoming page number parameter. For example, if we want to fetch the posts for page two, the skip parameter would be calculated as 1 x Config.pagination.pageSize, therefore skipping the results of page one.

If we want to fetch the posts for page six, the skip parameter would be calculated as 5 x Config.pagination.pageSize, and so on. When all your code is set up in your application, play around with the Config.pagination.pageSize to see this magic in action.

We’ve now set up all the API calls we need to get our data to pre-render our blog page routes at build time. Let’s fetch our data for page one on /blog.

Building the blog index with getStaticProps

The blog index will be available on /blog and will serve page one of our blog post summaries. For this reason, we can safely hardcode the number “1” in this file. This is great for readability — think self-documenting code!

Let’s pre-render this page at build time by exporting an async function called getStaticProps. Read more about getStaticProps on the Next.js documentation.

Add the following code to pages/blog/index.js.

// /pages/blog/index.js

import ContentfulApi from "@utils/ContentfulApi";
import { Config } from "@utils/Config";

export default function BlogIndex(props) {
  const { postSummaries, currentPage, totalPages } = props;

  return (
    // We’ll build the post list component later
  );
}

export async function getStaticProps() {
  const postSummaries = await ContentfulApi.getPaginatedPostSummaries(1);
  const totalPages = Math.ceil(postSummaries.total / Config.pagination.pageSize);

  return {
    props: {
      postSummaries: postSummaries.items,
      totalPages,
      currentPage: "1",
    },
  };
}

We’re using getStaticProps() to:

  • Request the post summaries for page one and total number of posts from the API.

  • Calculate the total number of pages based on the number of posts and Config.pagination.pageSize.

  • Return the postSummaries.items, totalPages, and currentPage as props to the BlogIndex component.

Bonus content!

You’ll notice that the file imports from the utils directory in this example are imported using absolute paths via a module alias using @. This is a really neat way to avoid long relative path imports (../../../../..) in your Next.js application, which increases code readability.

You can define module aliases in a jsconfig.json file at the root of your project. Here’s the jsconfig.json file used in the Next.js Contentful blog starter:

// jsconfig.json

{
  "compilerOptions": {
    "baseUrl": "./",
    "paths": {
      "@components/*": ["components/*"],
      "@utils/*": ["utils/*"]
    }
  }
}

Read more on the official documentation.

We’re going to be creating a components directory later on in this post, so I recommend adding this jsconfig.json file to your project to make file imports super easy. Make sure you stop and start your development server after adding this new file to enable Next.js to pick up the changes.

So that’s fetching data for page one done! But how do we build the dynamic routes at build time based on how many blog posts we have, and how many posts we want to show per page?

Building the dynamic article list pages with getStaticPaths

The article list pages will be available on /blog/page/{pageNumber} starting with the second page (/blog/ is page one). This is where we need to use getStaticPaths() to define a list of paths that will be rendered to HTML at build time.The rendered paths are based on the total number of blog posts, and how many posts we want to show per page. 

Let’s tell Next.js which paths we want to statically render by exporting an async function called getStaticPaths. Read more about getStaticPaths on the Next.js documentation.

Add the following code to pages/blog/page/[page].js:

// /pages/blog/pages/[page].js

import ContentfulApi from "@utils/ContentfulApi";
import { Config } from "@utils/Config";

export default function BlogIndexPage(props) {
  const { postSummaries, totalPages, currentPage } = props;

  return (
    // We’ll build the post list component later
  );
}

export async function getStaticPaths() {
  const totalPosts = await ContentfulApi.getTotalPostsNumber();
  const totalPages = Math.ceil(totalPosts / Config.pagination.pageSize);

  const paths = [];

  /**
   * Start from page 2, so we don't replicate /blog
   * which is page 1
   */
  for (let page = 2; page <= totalPages; page++) {
    paths.push({ params: { page: page.toString() } });
  }

  return {
    paths,
    fallback: false,
  };
}

export async function getStaticProps({ params }) {
  const postSummaries = await ContentfulApi.getPaginatedPostSummaries(
    params.page,
  );
  const totalPages = Math.ceil(postSummaries.total / Config.pagination.pageSize);

  return {
    props: {
      postSummaries: postSummaries.items,
      totalPages,
      currentPage: params.page,
    },
  };

We’re using getStaticPaths() to:

  • Request the total number of posts from the Contentful API.

  • Calculate the total number of pages we need to build depending on the page size we defined.

  • Build a paths array that starts from page two (blog/page/2) and ends at the total number of pages we calculated.

  • Return the paths array to getStaticProps so that for each path, Next.js will request the data for the dynamic page number — params.page at build time.

  • We’re using fallback: false because we always want to statically generate these paths at build time. If we add more blog posts which changes the number of pages we need to render, we’d want to build the site again. This is usually done with webhooks that Contentful sends to your hosting platform of choice each time you publish a new change. Read more about the fallback key here.

In our dynamic route, we’re using getStaticProps() in a similar way to /blog, with the only difference being that we’re using params.page in the calls to the Contentful API instead of hardcoding page number “1.”

Now we have our blog post summary data from Contentful, requested at build time and passed to our blog index and dynamic blog pages. Great! Let’s build a component to display our posts on the front end.

Building the post list component

Let’s build a PostList component that we will use on the blog index and our dynamic routes.

Create a components directory at the route of your project, create a new directory inside that called PostList, and add a new file inside that directory called index.js.

Screenshot of postlist

The PostList renders an ordered list (<ol>) of article elements that display the date, title, tags and excerpt of the post via the JavaScript map() function. We use next/link to enable a client-side transition to the blog post itself. Also notice that we’re using the post.sys.id on the <li> element to ensure each element in the map has a unique key. Read more about keys in React.

This example uses react-markdown to render the markdown of the excerpt field. This package is an optional dependency. Using it depends on the amount of flexibility you require for displaying formatted text in the blog-post excerpt. If you’re curious, you can view the ReactMarkdownRenderers.js file in the example project repository. This is used to add CSS classes and formatting to the markdown returned from the API. 

If you’d like to use react-markdown with the renderer options provided in the example project, install the package via npm following the given instructions.

I’ve also included a couple of date-formatting functions for the HTML <time> element referenced below in this file on GitHub to help you out.


// /components/PostList/index.js

import Link from "next/link";
import ReactMarkdown from "react-markdown";
import ReactMarkdownRenderers from "@utils/ReactMarkdownRenderers";
import {
  formatPublishedDateForDateTime,
  formatPublishedDateForDisplay,
} from "@utils/Date";

export default function PostList(props) {
  const { posts } = props;

  return (
      <ol>
        {posts.map((post) => (
          <li key={post.sys.id}>
            <article>
              <time dateTime={formatPublishedDateForDateTime(date)}>
                {formatPublishedDateForDisplay(date)}
              </time>

              <Link href={`blog/${post.slug}`}>
                <a>
                  <h2>{post.title}</h2>
                </a>
              </Link>

              <ul>
                {tags.map((tag) => (
                  <li key={tag}>{tag}</li>
                ))}
              </ul>

              <ReactMarkdown
                children={post.excerpt}
                renderers={ReactMarkdownRenderers(post.excerpt)}
              />
            </article>
          </li>
        ))}
      </ol>
  );
}

Render your postList in your BlogIndex and BlogIndexPage components like so. Pass the totalPages and currentPage props in, too, as we’ll be using them in the final part of this guide.


// /pages/blog/index.js
// Do the same for /pages/blog/page/[page].js

import PostList from "@components/PostList";

export default function BlogIndex(props) {
  const { postSummaries, currentPage, totalPages } = props;

  return (
        <PostList 
            posts={postSummaries} 
            totalPages={totalPages}
            currentPage={currentPage}
       />
  );
}

You should now have your post list rendering on /blog and /blog/page/2. There’s one more piece to the puzzle! Let’s build a component to navigate back and forth in our pagination.

Building the pagination component

We’re going to make our lives really easy here! To ensure that our application can scale nicely and that we don’t have to battle with displaying or truncating a million page numbers when we’ve written a bazillion blog posts, we will be rendering only three UI elements inside our pagination component:

  • A “previous page” link

  • A current page / total pages indicator

  • A “next page” link

Inside components/PostList, add a new directory called Pagination. Inside that directory, add a new file called index.js.

Screenshot of pgination in Contentful

Add the following code to index.js.

// /components/PostList/Pagination/index.js

import Link from "next/link";

export default function Pagination(props) {
  const { totalPages, currentPage, prevDisabled, nextDisabled } = props;

  const prevPageUrl =
    currentPage === "2"
      ? "/blog"
      : `/blog/page/${parseInt(currentPage, 10) - 1}`;

  const nextPageUrl = `/blog/page/${parseInt(currentPage, 10) + 1}`;

  return (
    <ol>
      <li>
        {prevDisabled && <span>Previous page</span>}
        {!prevDisabled && (
          <Link href={prevPageUrl}>
            <a>Previous page</a>
          </Link>
        )}
      </li>
      <li>
        Page {currentPage} of {totalPages}
      </li>
      <li>
        {nextDisabled && <span>Next page</span>}
        {!nextDisabled && (
          <Link href={nextPageUrl}>
            <a>Next page</a>
          </Link>
        )}
      </li>
    </ol>
  );
}

We’re using the next/link component to make use of client-side routing, and we’re calculating the links to the next and previous pages based on the currentPage prop.

Import the Pagination component at the top of the PostListfile, and add it at the end of the template rendering the HTML. Pass in the totalPages and currentPages props.

Next, calculate the nextDisabled and prevDisabled variables based on the currentPage and totalPages:

  • If we’re on the page one, prevDisabled = true

  • If we’re on the last page, nextDisabled = true

Finally, pass these two props to the Pagination component.

// /components/PostList/index.js

import Pagination from "@components/PostList/Pagination";

export default function PostList(props) {
 // Remember to take the currentPage and totalPages from props passed
 // from the BlogIndex and BlogIndexPage components
  const { posts, currentPage, totalPages } = props;

 // Calculate the disabled states of the next and previous links
  const nextDisabled = parseInt(currentPage, 10) === parseInt(totalPages, 10);
  const prevDisabled = parseInt(currentPage, 10) === 1;

  return (
    <>

      // Post list <ol>...

      <Pagination
        totalPages={totalPages}
        currentPage={currentPage}
        nextDisabled={nextDisabled}
        prevDisabled={prevDisabled}
      />
    </>
  );
}

And that’s it! You’ve built statically generated article list pages based on the number of blog posts in the example Contentful space and how many posts you’d like to show per article list page. 

The finished product

In this tutorial we built statically generated article list pagination using data from Contentful in a fresh Next.js application. You can find the final styled result here, and here’s how it looks.

Screenshot of blog index

If you want to look at how the demo site is styled with CSS, take a look at these files on GitHub.

If you’ve set up a webhook inside Contentful to trigger a build every time you publish a change, your article list pages will be rebuilt, and continue to generate the /blog/page/{pageNumber} routes dynamically based on how many blog post entries you have!

If you’ve found this guide useful, I’d love for you to come and say hi on Twitch, where I code live three times a week. I built this code on stream!

And remember, build stuff, learn things and love what you do.

About the author

Don't miss the latest

Get updates in your inbox
A monthly newsletter to help you build better digital experiences with Contentful.
add-circle arrow-right remove style-two-pin-marker subtract-circle