DEV Community

Cover image for Pagination in Next.js using SSG
Fabian Reinders
Fabian Reinders

Posted on • Updated on

Pagination in Next.js using SSG

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.

Screenshot of an example pagination on a website

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

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

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.

Screenshot of paginated blog posts overview - First page

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

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

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.

Screenshot of paginated blog posts overview - Following page

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

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)