17.6 Previous and Next Page Navigation
Right, let’s talk about navigating between your single pieces of content. You’ve built a beautiful template for your blog posts or product pages, but leaving your reader at a dead end after they finish reading is a bit like a stand-up comedian finishing a killer joke and then just walking off stage into a broom closet. We need to give them a graceful exit, or better yet, a path to the next interesting thing. This isn’t just good UX; it’s good hospitality.
The core idea is stupidly simple: from your current page, you figure out what the immediate sibling pages are in the collection. The implementation, of course, is where the CMS designers and static site generator folks get to have their fun. We’ll get into that.
The Core Concept: It’s Just a Sorted List
Think of all your posts (or pages, or products) as items in a sorted list. By default, they’re usually sorted by date. Your current page is just one item in that list. The “next” page is the one that comes after it in the list; the “previous” page is the one that comes before it. That’s it. That’s the whole mental model. The complexity comes from how your particular tooling (I’m looking at you, Hugo, Next.js, Eleventy…) makes you access that list.
Implementation in Next.js (App Router)
Here’s how you’d typically do this in a Next.js 14+ App Router project. You’d fetch the list of all your posts, find the current one, and then pluck out its neighbors. This logic is perfect for a utility function.
// lib/get-adjacent-posts.js
export async function getAdjacentPosts(currentSlug) {
// 1. Get all your posts. This assumes you're fetching from somewhere.
// Using a CMS? Use your CMS client. Using local files? Use `fs`.
const allPosts = await getAllPostsSortedByDate(); // You'd write this function
// 2. Find the index of the current post in that big array.
const currentIndex = allPosts.findIndex(post => post.slug === currentSlug);
// 3. The previous post is the one *before* the current one (hence index - 1).
// If we're on the first post, there is no previous. Be nice and return `null`.
const previousPost = currentIndex > 0 ? allPosts[currentIndex - 1] : null;
// 4. The next post is the one *after* the current one (index + 1).
// If we're on the last post, there is no next.
const nextPost = currentIndex < allPosts.length - 1 ? allPosts[currentIndex + 1] : null;
return { previousPost, nextPost };
}
Now, inside your page component (app/posts/[slug]/page.js), you’d use this function.
// app/posts/[slug]/page.js
import { getAdjacentPosts } from '@/lib/get-adjacent-posts';
export default async function PostPage({ params }) {
const { slug } = params;
// ... your code to fetch the current post's content ...
const { previousPost, nextPost } = await getAdjacentPosts(slug);
return (
<article>
{/* Your beautiful article content here... */}
<nav className="flex justify-between mt-16 border-t pt-8">
{previousPost ? (
<Link href={`/posts/${previousPost.slug}`} className="text-lg font-medium">
← {previousPost.title}
</Link>
) : (
<span /> // This empty span is a cheap trick to keep the next link on the right with flexbox justify-between
)}
{nextPost ? (
<Link href={`/posts/${nextPost.slug}`} className="text-lg font-medium text-right">
{nextPost.title} →
</Link>
) : (
<span />
)}
</nav>
</article>
);
}
The Sorting Dilemma (And Why It’s a Pitfall)
Here’s the first “questionable choice” you’ll run into: what are you sorting by? date? What if you have two posts with the exact same timestamp? Your sort becomes unpredictable. It’s a classic pitfall. Always ensure your sorting has a tie-breaker. I always sort by two fields: first by date (descending, so newest first), and then by title (ascending). This creates a stable, predictable order.
// A more robust getAllPostsSortedByDate function
function getAllPostsSortedByDate() {
// ... fetch posts ...
return posts.sort((a, b) => {
// First, compare by date. Newest first.
const dateComparison = new Date(b.publishDate) - new Date(a.publishDate);
if (dateComparison !== 0) return dateComparison;
// If dates are identical, compare by title to break the tie.
return a.title.localeCompare(b.title);
});
}
Edge Cases: The Invisible Booby Traps
You must handle the edges. What if you’re on the very first post? Your previousPost will be null. If you blindly try to render a link to previousPost.slug, your site will throw a nasty error and crash. This is why we use conditional rendering ({previousPost ? ... : null}) in the JSX example above. The same goes for the last post and nextPost. It seems obvious, but you’d be surprised how many people forget to test the first and last items in the list.
A Note on “Next” and “Previous” Semantics
A quick semantic quibble: on a list sorted by date from newest to oldest (the standard blog chronology), the “next” post is actually older than the current one. This feels backwards to a lot of people. “Next” implies moving forward in time, but you’re actually moving backward through the timeline. It’s a weird convention we’re all stuck with. You could call them “Older” and “Newer” if you want to be painfully literal, but that often confuses people even more. I just stick with the established pattern and accept the slight absurdity. Choose your battles.