Paginating Blog Posts In Next.js (App Directory)

Paginating a Next.js app using the app directory has a few caveats, so this post goes through an example to make life easier.

Paginating Blog Posts In Next.js (App Directory)
Pawel Czerwinski

I recently set up pagination for my blog "list" page here on this very website:

Screenshot of pagination links

Because the Next.js app directory & router are still relatively new (at the time of writing) it was a little bit confusing. So if you're trying to do something similar, read on!

My Preferred URLs

I wanted a pretty specific URL schema for the pages:

/blog/
/blog/page/2/
/blog/page/3/
/blog/page/4/

In particular, I wanted to keep /blog/ as my page 1 to avoid having to create any redirects that might negatively affect existing SEO.

About URL segments

The reason I've included page in my URL segments (/blog/page/2/ and not just /blog/2/) is actually down to the specifics of dynamic routing in Next.js.

You can't (easily?) have two routes resolve on the same URL. Without including page in by URLs, the paginated pages would look like regular blog posts and therefore resolve to my blog "detail" route incorrectly.

Overview

Here's a breakdown of what we need to do:

  1. Update our blog "list" page to a "dynamic route", specifically using "optional catch-all segments".
  2. Use generateStaticParams in our blog "list" route to generate all of our pages.
  3. Update our actual blog list component to filter only the posts relevant to the page we're viewing.
  4. Create a new pagination component.

The Existing Blog

First, let's look at what our existing blog looks like before making any changes. Our app directory looks like:

app/blog
├── [slug]
│   └── page.tsx    <== "detail" page i.e. /blog/my-post/
└── page.tsx        <== "list" page i.e. /blog/

And here's our actual BlogListPage component:

app/blog/page.tsx
import { allPosts } from "contentlayer/generated";
import { compareDesc } from "date-fns";
 
import BlogPostListItem from "../components/BlogPostListItem";
 
const BlogListPage = ({ params }) => {
  const posts = allPosts.sort((a, b) =>
    compareDesc(new Date(a.date), new Date(b.date))
  );
 
  return (
    <div>
      <ul>
        {posts.map((post, i) => (
          <li key={i}>
            <BlogPostListItem post={post} />
          </li>
        ))}
      </ul>
    </div>
  );
};
 
export default BlogListPage;
  • Note that I'm using ContentLayer to fetch my blog post content via allPosts (which is just local Markdown/MDX files).
  • I'm sorting my posts by their date.
  • I've not included the component for actually rendering the blog posts.

1. Update Blog List Route

To get pagination set up, create a new folder in your blog app/blog/[[...id]] and move your existing app/blog/page.tsx into it:

app/blog
├── [[...id]]       <== Your new dynamic route folder
│   └── page.tsx
├── [slug]
    └── page.tsx

Naming your folder [[...id]] folder enables a new dynamic route with "optional catch-all segments".

This is what allows us to create a "bare" /blog/ page that shows our first page of posts as well as numerous nested pages like /blog/2/, /blog/3/ etc.

2. Generate Pages

Now open up your app/blog/[[...id]]/page.tsx and add a generateStaticParams function:

app/blog/[[..id]]/page.tsx
import { allPosts } from "contentlayer/generated";
import { compareDesc } from "date-fns";
 
import BlogPostListItem from "../components/BlogPostListItem";
 
const BlogListPage = ({ params }) => {
  const posts = allPosts.sort((a, b) =>
    compareDesc(new Date(a.date), new Date(b.date))
  );
 
  return (
    <div>
      <ul>
        {posts.map((post, i) => (
          <li key={i}>
            <BlogPostListItem post={post} />
          </li>
        ))}
      </ul>
    </div>
  );
};
 
const generateStaticParams = async () => {
  const n = Math.ceil(allPosts.length / PER_PAGE);
  const arr = Array.from({ length: n }, (v, i) => {
    return i === 0
      ? undefined
      : {
          id: ["page", i.toString()],
        };
  });
  return arr;
};
 
export { generateStaticParams };
 
export default BlogListPage;

What we're doing here is telling Next.js to generate the following pages:

/blog/
/blog/page/2/
/blog/page/3/
...

By essentially slicing up all of our blog posts into chunks of PER_PAGE (5).

Note that this function returns an array that looks like:

[
  undefined,
  {
    id: ["page", "2"],
  },
  {
    id: ["page", "3"],
  },
  {
    id: ["page", "4"],
  },
];
  • Leaving the first item as undefined allows us to have a root /blog/ page.
  • When using "optional catch-all segments", you need to return an array of URL segments. The values in the arrays should be string and not number

3. Update Blog List Component

We now need to update our actual blog list component to filter our blog posts based on the current page being rendered.

app/blog/[[..id]]/page.tsx
import { allPosts } from "contentlayer/generated";
import { compareDesc } from "date-fns";
 
import BlogPostListItem from "../components/BlogPostListItem";
 
const PER_PAGE = 5;
 
const BlogListPage = ({ params }) => {
  const page = params.id ? parseInt(params.id[1]) : 1;
  const totalPages = Math.ceil(allPosts.length / PER_PAGE);
  const posts = allPosts
    .sort((a, b) => compareDesc(new Date(a.date), new Date(b.date)))
    .slice((page - 1) * PER_PAGE, page * PER_PAGE);
 
  return (
    <div>
      <ul>
        {posts.map((post, i) => (
          <li key={i}>
            <BlogPostListItem post={post} />
          </li>
        ))}
      </ul>
    </div>
  );
};
 
const generateStaticParams = async () => {
  const n = Math.ceil(allPosts.length / PER_PAGE);
  const arr = Array.from({ length: n }, (v, i) => {
    return i === 0
      ? undefined
      : {
          id: ["page", i.toString()],
        };
  });
  return arr;
};
 
export { generateStaticParams };
 
export default BlogListPage;
  • If rendering the /blog/ page, the params will be undefined. This is essentially "page 1".
  • If rendering the /blog/page/2 page, params.id will be "2"

We use this id param to calculate what page we're on and slice our posts to only show those that belong to this page.

4. The Pagination Component

Finally, we need to create the actual pagination component to display the clickable links to all the other pages:

app/blog/[[..id]]/page.tsx
import { allPosts } from "contentlayer/generated";
import { compareDesc } from "date-fns";
 
import BlogPostListItem from "../components/BlogPostListItem";
 
const PER_PAGE = 5;
 
const Pagination = ({ page, totalPages }) => {
  return (
    <>
      <ul className="flex items-center text-xl gap-5">
        {page - 1 > 0 && (
          <li>
            <Link href={page === 0 ? "/blog/" : `/blog/page/${page - 1}/`}>

            </Link>
          </li>
        )}
        {Array.from({ length: totalPages }, (v, i) => {
          return (
            <li key={i} className="hover:underline">
              {page === i + 1 ? (
                <span className="underline">{i + 1}</span>
              ) : (
                <Link href={i === 0 ? "/blog/" : `/blog/page/${i + 1}/`}>
                  {i + 1}
                </Link>
              )}
            </li>
          );
        })}
        {page + 1 <= totalPages && (
          <li>
            <Link href={`/blog/page/${page + 1}/`}>→</Link>
          </li>
        )}
      </ul>
    </>
  );
};
 
const BlogListPage = ({ params }) => {
  const page = params.id ? parseInt(params.id[1]) : 1;
  const totalPages = Math.ceil(allPosts.length / PER_PAGE);
  const posts = allPosts
    .sort((a, b) => compareDesc(new Date(a.date), new Date(b.date)))
    .slice((page - 1) * PER_PAGE, page * PER_PAGE);
 
  return (
    <div>
      <ul>
        {posts.map((post, i) => (
          <li key={i}>
            <BlogPostListItem post={post} />
          </li>
        ))}
      </ul>
      <Pagination page={page} totalPages={totalPages} />
    </div>
  );
};
 
const generateStaticParams = async () => {
  const n = Math.ceil(allPosts.length / PER_PAGE);
  const arr = Array.from({ length: n }, (v, i) => {
    return i === 0
      ? undefined
      : {
          id: ["page", i.toString()],
        };
  });
  return arr;
};
 
export { generateStaticParams };
 
export default BlogListPage;

And there we have it, a paginated blog page!