# Implementing Pagination with Next.js

> Source: https://agilitycms.com/docs/nextjs/implementing-pagination-with-next-js

Agility's List endpoint returns a page of items at a time — `take` defaults to 50 and caps at **250** — so any list longer than that needs paging via `skip` and `take`. Every list response also includes `totalCount`, which is what you page against.

This guide shows the **App Router** approach: paginate on the server, in a Server Component, driven by the URL.

> **Coming from the Pages Router?** The old recipe used `getCustomInitialProps` to fetch an initial page at build time, then a client component that called a `/pages/api` route for "load more". None of those pieces exist in the App Router — there's no `getCustomInitialProps`, and you rarely need a custom API route, because a Server Component can `await` the SDK directly. The pattern below is simpler and ships less JavaScript.

## Why URL-driven paging is the default

Put the page number in the URL (`/blog?page=2`) rather than in React state:

- every page is linkable, shareable and indexable
- the back button works
- page 2 renders on the server, so it needs **no client JavaScript at all**
- each page is independently cacheable

Reach for a "Load more" button only when the UX genuinely calls for it — and even then, keep the first page server-rendered.

## 1. Read the page number from `searchParams`

```tsx
// app/[locale]/blog/page.tsx
import { getContentList } from "@/lib/cms/getContentList"
import { getAgilityContext } from "@/lib/cms/getAgilityContext"

const PAGE_SIZE = 10

export default async function BlogListing({ params, searchParams }) {
  const { locale } = await params
  const { page: pageParam } = await searchParams
  const { isPreview } = await getAgilityContext(locale)

  // clamp: a hand-edited ?page=-5 or ?page=abc must not break the query
  const page = Math.max(1, parseInt(String(pageParam ?? "1"), 10) || 1)

  const posts = await getContentList({
    referenceName: "posts",
    languageCode: locale,
    preview: isPreview,
    take: PAGE_SIZE,
    skip: (page - 1) * PAGE_SIZE,
    sort: "fields.postDate",
    direction: "desc",
  })

  const totalPages = Math.ceil(posts.totalCount / PAGE_SIZE)

  return (
    <>
      <ul>
        {posts.items.map((post) => (
          <li key={post.contentID}>{post.fields.title}</li>
        ))}
      </ul>
      <Pagination page={page} totalPages={totalPages} />
    </>
  )
}
```

> **`searchParams` is a Promise** in the App Router (Next 15+) and must be awaited. Reading it also makes the route dynamic — see "Keeping it static" below.

## 2. The pagination control

A plain server component of links. No state, no JavaScript:

```tsx
import Link from "next/link"

function Pagination({ page, totalPages }: { page: number; totalPages: number }) {
  if (totalPages <= 1) return null

  return (
    <nav aria-label="Pagination">
      {page > 1 && (
        <Link href={page === 2 ? "/blog" : `/blog?page=${page - 1}`} rel="prev">
          Previous
        </Link>
      )}
      <span aria-current="page">Page {page} of {totalPages}</span>
      {page < totalPages && (
        <Link href={`/blog?page=${page + 1}`} rel="next">
          Next
        </Link>
      )}
    </nav>
  )
}
```

Linking page 1 to `/blog` rather than `/blog?page=1` keeps one canonical URL for the first page.

## 3. Keeping it static with Cache Components

Reading `searchParams` is request-time data, so the route renders dynamically. With [Cache Components](/docs/nextjs/caching-with-next-js-and-agility) you can still prerender the shell and stream only the list, by putting the `searchParams` read inside a `<Suspense>` boundary:

```tsx
import { Suspense } from "react"

export default async function BlogListing({ params, searchParams }) {
  const { locale } = await params
  return (
    <>
      <h1>Blog</h1>                   {/* prerendered instantly */}
      <Suspense fallback={<PostsSkeleton />}>
        <PostList locale={locale} searchParams={searchParams} />
      </Suspense>
    </>
  )
}

async function PostList({ locale, searchParams }) {
  const { page: pageParam } = await searchParams   // request-time, inside Suspense
  // ...fetch and render as above
}
```

The list itself still comes from a cached, tagged read, so publishing a post invalidates it immediately.

## 4. Prerendering every page of the list

If you'd rather every page be fully static, use a route segment instead of a query string — `/blog/page/2` — and enumerate them:

```tsx
// app/[locale]/blog/page/[page]/page.tsx
export async function generateStaticParams() {
  const posts = await getContentList({
    referenceName: "posts",
    languageCode: "en-us",
    preview: false,
    take: 1,                       // we only want totalCount
  })

  const totalPages = Math.ceil(posts.totalCount / PAGE_SIZE)
  return Array.from({ length: totalPages }, (_, i) => ({ page: String(i + 2) }))
}
```

Trade-off: publishing enough new posts to create a *new* page of results won't produce that route until the next build, unless your webhook triggers one.

## 5. "Load more", when you really need it

Keep the first page server-rendered, and append with a Server Action:

```tsx
// app/actions.ts
"use server"
import { getContentList } from "@/lib/cms/getContentList"

export async function loadMorePosts(skip: number) {
  const posts = await getContentList({
    referenceName: "posts",
    languageCode: "en-us",
    preview: false,
    take: 10,
    skip,
  })
  return posts.items
}
```

A small client component calls that action and appends the results. **No `/pages/api` route, no `axios`** — the Server Action is the endpoint, and it's type-safe.

If you do this, still render page 1 on the server and offer real paginated URLs as a fallback, or you lose crawlability for everything past the first page.

## Gotchas

- **Always pass an explicit `take`.** The default is 50 and the cap is 250. A list that quietly stops at 50 items is one of the most common Agility bugs.
- **Clamp the page number.** `?page=abc` or `?page=-1` must not reach `skip`.
- **Sort explicitly.** Without `sort`, ordering isn't guaranteed stable across requests, and an item can appear on two pages or none.
- **`totalCount` is the total in the container**, not the number returned — that's what you divide by `PAGE_SIZE`.

## Summary

1. Put the page in the URL; read it from `searchParams` in a Server Component.
2. `take` + `skip`, always explicit, always sorted, and clamp the input.
3. Under Cache Components, wrap the `searchParams` read in `<Suspense>` to keep a static shell.
4. Use route segments (`/blog/page/2`) if you want every page prerendered.
5. Use a Server Action for "load more" — not an API route.
