Back to Blog
Lesson 32 of the Next.js: Build Full-Stack Apps with the App Router course
Next.jsAugust 19, 20264 min read

Paginated Post Lists: A Practical Guide for Next.js

Learn how to implement pagination in Next.js. Master URL-driven data fetching to improve performance and UX when displaying large lists of blog posts.

Next.jspaginationperformanceUXdatabaseserver components

Previously in this course, we covered fetching data from the database to render your blog posts. As your project grows, dumping every single post onto one page will eventually crush your browser's performance and slow down your database. In this lesson, we'll implement pagination to keep our UI snappy and our data manageable.

Why Pagination Matters for Performance and UX

Fetching an entire database table is rarely a good idea. As discussed in The Need for Pagination: Scaling API Performance and Memory, loading hundreds of items at once leads to high memory usage and long Time to First Byte (TTFB) metrics.

By implementing pagination, we provide:

  • Improved Performance: Only fetching what the user sees.
  • Better UX: Users can navigate through content in logical blocks.
  • SEO Benefits: Search engines can crawl content more effectively when partitioned.

Understanding Offset Pagination

We will use offset-based pagination. This is the standard approach for most blog interfaces where users navigate "pages" (Page 1, 2, 3...). You can read more about the mechanics of this in Limiting and Offsetting Results: Mastering PostgreSQL Pagination.

In Next.js, we track the current page using URL search parameters (e.g., /blog?page=2). This is a best practice because it makes the state shareable and bookmarkable.

Worked Example: Implementing Pagination

To implement this, we need to modify our server component to read the searchParams prop and apply them to our Prisma query.

1. Updating the Server Component

In your app/blog/page.js, accept searchParams as a prop.

JAVASCRIPT
import { prisma } from "@/lib/prisma";
import PostList from "@/components/PostList";
import PaginationControls from "@/components/PaginationControls";

export default async function BlogPage({ searchParams }) {
  const page = parseInt(searchParams.page) || 1;
  const limit = 5;
  const skip = (page - 1) * limit;

  const [posts, totalCount] = await Promise.all([
    prisma.post.findMany({
      skip,
      take: limit,
      orderBy: { createdAt: "desc" },
    }),
    prisma.post.count(),
  ]);

  const totalPages = Math.ceil(totalCount / limit);

  return (
    <div>
      <PostList posts={posts} />
      <PaginationControls currentPage={page} totalPages={totalPages} />
    </div>
  );
}

2. Creating the Pagination Controls

Use the Link component to update the URL parameters. This ensures the page stays performant by leveraging Next.js's built-in client-side navigation.

JAVASCRIPT
import Link from "next/link";

export default function PaginationControls({ currentPage, totalPages }) {
  return (
    <div className="flex gap-4 mt-8">
      {currentPage > 1 && (
        <Link href={CE9178">`/blog?page=${currentPage - 1}`}>Previous</Link>
      )}
      <span>Page {currentPage} of {totalPages}</span>
      {currentPage < totalPages && (
        <Link href={CE9178">`/blog?page=${currentPage + 1}`}>Next</Link>
      )}
    </div>
  );
}

Hands-on Exercise

  1. Calculate the offset: Update your logic to ensure skip never becomes a negative number (e.g., if a user manually types ?page=-5 in the URL).
  2. Add a "First" and "Last" button: Extend the PaginationControls component to include direct links to the first and last pages.
  3. Style it: Use your Tailwind skills from Styling with Tailwind CSS to make the pagination buttons look like a proper UI component.

Common Pitfalls

  • Ignoring the Total Count: Always fetch the total count of items so your UI knows when to disable the "Next" button.
  • Over-fetching: Ensure your limit (page size) is reasonable. Don't fetch 100 items if the user only needs to see 5 or 10.
  • URL Desync: Always use Link components for pagination. If you use standard <a> tags, you'll cause a full page refresh, losing the benefits of the Next.js router.
  • Large Datasets: Offset pagination gets slower as you go deeper into the results. For massive datasets (millions of rows), consider Cursor-based Pagination: High-Performance API Design instead.

FAQ

Q: Why use searchParams instead of internal state? A: searchParams allows the URL to act as the "source of truth." If a user refreshes the page or shares the link, they remain on the same page of results.

Q: Can I use useEffect to fetch data when the page changes? A: Avoid it. In the App Router, data fetching should happen in Server Components using searchParams to ensure the data is ready before the page reaches the client.

Q: How do I handle empty pages? A: If a user navigates to a page that doesn't exist (e.g., /blog?page=999), ensure your query returns an empty array and display a "No posts found" message rather than crashing.

Recap

We’ve successfully moved from a flat list to a dynamic, paginated view. By using searchParams to drive our database queries, we've improved our app's performance and enabled users to browse content systematically.

Up next: We will learn how to optimize typography and performance by using Font Optimization.

Similar Posts