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.
I recently set up pagination for my blog "list" page here on this very website:
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:
- Update our blog "list" page to a "dynamic route", specifically using "optional catch-all segments".
- Use
generateStaticParams
in our blog "list" route to generate all of our pages. - Update our actual blog list component to filter only the posts relevant to the page we're viewing.
- 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:
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:
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 notnumber
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.
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, theparams
will beundefined
. 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:
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!