In this blog post, I will break down how I implemented pagination on my Next.js blog using Static Site Generation (SSG).
What is Pagination?
Pagination is a common technique used in web development to improve user experience when navigating through a large set of data. If you are building a blog using Next.js, you may need to implement pagination to display a limited number of blog posts per page.
In this article, we will discuss how to implement pagination in Next.js using Static Site Generation (SSG) or Incremental Static Regeneration (ISR).
As a real-world example, we'll take my blog that is built using Next.js and Storyblok CMS. I recently implemented pagination for the overview page showing all blog posts.
Feel free to take a look at the full code for that on GitHub.
The URL structure
First we need come up with a structure for the URLs as the pagination "parameters" like the current page need to be in there for SSG or ISR to work. For that we make use of Next.js Dynamic Routes.
The structure I decided to use:
/blog
Simply redirecting to /blog/posts/
.
/blog/posts
First page showing the latest X blog posts.
/blog/posts/[page]
The other pages reachable through the pagination.
Note that this is only an example and you can design your URLs any way you want.
The file structure
In the next step, we need to create a file structure in our Next.js app according to our URL schema.
In my case it looks like this:
.
└── pages/
└── blog/
└── posts/
├── [page].tsx
└── index.tsx
The Pagination component
To easily include a pagination at the bottom of our pages, we create a <Pagination />
component taking all necessary pagination parameters in as props.
You will have to style this component yourself.
For inspiration you can take a look at my original code on GitHub for this component.
import Link from 'next/link';
interface PaginationProps {
currentPage: number; // Page the user is currently on.
totalPages: number; // Total number of pages.
}
const Pagination = ({ currentPage, totalPages }: PaginationProps) => {
const pageNumbers = [];
for (let i = 1; i <= totalPages; i++) {
pageNumbers.push(i);
}
return (
<nav id="blog-posts-pagination">
<ul>
{/* The "Prev" button, if needed. */}
{currentPage > 1 && (
<li>
<Link href={currentPage - 1 <= 1 ? '/blog/posts' : `/blog/posts/${currentPage - 1}`}>Prev</Link>
</li>
)}
{/* The individual pages. */}
{pageNumbers.map((number) => (
<li key={number}>
{/* Current page: Page not clickable. */}
{currentPage === number && <button disabled>{number}</button>}
{/* Other pages: Page is clickable. */}
{currentPage !== number && <Link href={number === 1 ? '/blog/posts' : `/blog/posts/${number}`}>{number}</Link>}
</li>
))}
{/* The "Next" button, if needed. */}
{currentPage < totalPages && (
<li>
<Link href={`/blog/posts/${currentPage + 1}`}>Next</Link>
</li>
)}
</ul>
</nav>
);
};
export default Pagination;
The first page
This is the "first" page in the pagination. I designed the structure so that /blog/posts/1
doesn't exist (or is being redirect) and instead /blog/posts
is the main URL for the blog posts overview.
Implementing the index.tsx page
index.tsx
is the file responsible for this page.
Note that this is a shortened example from my blog. I use Storyblok as a CMS and therefore fetch the content using their API. You can of course use any API or internal file structure you want and apply the same concept.
/**
* Constant to determine how many blog posts are shown per page.
*/
const POSTS_PER_PAGE = 15;
/**
* Blog overview page.
* Posts are still being rendered by [...slug].tsx.
*/
const BlogOverviewPage = ({ blogPosts, pagination }) => {
return (
<Layout>
<>
{/* This component will render out the collection of blog posts passed as a prop. */}
<BlogPosts blogPosts={blogPosts} />
{/* This is our pagination component. */}
{pagination.totalPages > 1 && <Pagination currentPage={pagination.currentPage} totalPages={pagination.totalPages} />}
</>
</Layout>
);
};
export const getStaticProps: GetStaticProps = async () => {
// Calculate how many blog posts there are by counting all links starting with 'blog/'.
const blogPostSlugs = api.getSlugs(...);
// Total count of blog posts in Storyblok.
const blogPostTotalCount = Object.keys(blogPostSlugs.links).length - 1; // -1 because the blog overview page is also counted.
// Total number of /blog/posts pages (including index).
const totalPages = Math.ceil(blogPostTotalCount / POSTS_PER_PAGE);
// Retrieve blog posts (without content).
const blogPosts = api.getBlogPosts(...);
if (blogPosts.length === 0) {
return {
notFound: true,
revalidate: 5 * 60, // revalidate every 5 minutes.
};
}
return {
props: {
blogPosts,
pagination: {
currentPage: 1,
totalPages: totalPages,
},
},
revalidate: 30 * 60, // revalidate every 30 minutes.
};
};
export default BlogOverviewPage;
Explanation
We have a constant POSTS_PER_PAGE
that defines how many posts we want to render per page.
Usually, APIs that support pagination (like in my case Storyblok) have a per_page
option or something similar.
Then, we need to pass the current page to the API as well so it can figure out what posts to return.
In Storyblok's case that is the page
parameter.
Additionally, we need to calculate the total number of pages for our <Pagination />
component.
You can do so by using the following calculation:
Math.ceil(blogPostTotalCount / POSTS_PER_PAGE);
The dynamic sub-pages
Let's say we render out the 15 latest posts on /blog/posts
.
Then we need a /blog/posts/2
page for the next 15, a /blog/posts/3
page for the next 15, and so on...
This is implemented in the dynamic route [page].tsx
.
Implementing the [page].tsx pages
/**
* Constant to determine how many blog posts are shown per page.
*/
const POSTS_PER_PAGE = 15;
/**
* Paginated blog overview page (eg. /blog/posts/3).
* Posts are still being rendered by [...slug].tsx.
*/
const PaginatedBlogOverviewPage = ({ blogPosts, pagination }) => {
return (
<Layout>
<>
<BlogPosts blogPosts={blogPosts} />
{pagination.totalPages > 1 && <Pagination currentPage={pagination.currentPage} totalPages={pagination.totalPages} />}
</>
</Layout>
);
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
const page = parseInt(String(params?.page));
// Calculate how many blog posts there are by counting all links starting with 'blog/'.
const blogPostSlugs = api.getPostSlugs(...);
// Total count of blog posts in Storyblok.
const blogPostTotalCount = Object.keys(blogPostSlugs.links).length - 1; // -1 because the blog overview page is also counted.
// Total number of /blog/posts pages (including index).
const totalPages = Math.ceil(blogPostTotalCount / POSTS_PER_PAGE);
if (page > totalPages) {
return {
notFound: true,
revalidate: 5 * 60, // revalidate every 5 minutes.
};
}
// Retrieve stories for all blog posts (without content).
const blogPosts = api.getBlogPosts(...);
if (blogPosts.length === 0) {
return {
notFound: true,
revalidate: 5 * 60, // revalidate every 5 minutes.
};
}
return {
props: {
blogPosts,
pagination: {
currentPage: storyblokParams['page'],
totalPages: totalPages,
},
},
revalidate: 30 * 60, // revalidate every 30 minutes.
};
};
export const getStaticPaths: GetStaticPaths = async () => {
// Calculate how many blog posts there are by counting all links starting with 'blog/'.
const blogPostSlugs = api.getPostSlugs(...);
// Total count of blog posts in Storyblok.
const blogPostTotalCount = Object.keys(blogPostSlugs.links).length - 1; // -1 because the blog overview page is also counted.
// Total number of overview pages (including the index page).
const totalPages = Math.ceil(blogPostTotalCount / Number(POSTS_PER_PAGE));
// Define array of paths and other options (returned from this function).
let staticPathsResult: GetStaticPathsResult = {
paths: [],
fallback: 'blocking',
};
for (let i = 2; i < totalPages + 1; i++) {
staticPathsResult.paths.push({
params: {
page: i.toString(),
},
});
}
return staticPathsResult;
};
export default PaginatedBlogOverviewPage;
Explanation
The only difference here is that we need a getStaticPaths()
method that tells Next.js all the pages it needs to build (during build-time).
See the Next.js documentation for reference:
https://nextjs.org/docs/basic-features/data-fetching/get-static-paths
If you want to use ISR, you can define a fallback
, like I did in this example.
Also, because this is a dynamic route and "blueprint" for all sub-pages, we have the parameter page
that's contained in the URL that we can pass to the API and pagination as the current page.
Example:
When a user visits /blog/posts/3
, params.page
is 3
.
Conclusion and things to consider
I hope this gave you a basic idea of how one could go about implementing pagination on static sites in Next.js.
Be careful when implementing paginated content in Next.js using SSG or ISR.
This is the perfect implementation if you only have a couple of pages. It's going to load extremely fast and give the user as well as search engines a great experience! However, please consider that with thousands of pages this could increase the build time a lot and a server-side-rendered approach might be more suitable for that purpose.
In addition to what I've shown here, I would encourage you to also pay attention to SEO. Content behind a pagination is more complicated to figure out for search engines. However, if you get this right, pagination could even improve your SEO ranking as the pages are smaller and load faster.
I actually wrote an article on optimizing SEO for paginated content on my blog:
https://fabiancdng.com/blog/seo-for-developers-pagination
Lastly, it might make sense to take a look at the full, real-world code on GitHub to see everything put together.
Cheers!
📣 This post was originally published on my website on April 8, 2023.
Top comments (0)