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!
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.
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 timegetStaticPaths
: 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.
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!)
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!
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?
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)
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?
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:
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.
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
.
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.
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?
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.
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
, andcurrentPage
as props to theBlogIndex
component.
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?
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.
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
.
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.
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
.
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 PostList
file, 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
= trueIf 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.
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.
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.