What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
Tutorials
Paginate an Agility content list in the Next.js App Router — URL-driven paging in a Server Component with skip and take, keeping a static shell under Cache Components, and a Server Action for load-more.
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
getCustomInitialPropsto fetch an initial page at build time, then a client component that called a/pages/apiroute for "load more". None of those pieces exist in the App Router — there's nogetCustomInitialProps, and you rarely need a custom API route, because a Server Component canawaitthe SDK directly. The pattern below is simpler and ships less JavaScript.
Put the page number in the URL (/blog?page=2) rather than in React state:
Reach for a "Load more" button only when the UX genuinely calls for it — and even then, keep the first page server-rendered.
searchParams// 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} />
</>
)
}
searchParamsis a Promise in the App Router (Next 15+) and must be awaited. Reading it also makes the route dynamic — see "Keeping it static" below.
A plain server component of links. No state, no JavaScript:
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.
Reading searchParams is request-time data, so the route renders dynamically. With Cache Components you can still prerender the shell and stream only the list, by putting the searchParams read inside a <Suspense> boundary:
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.
If you'd rather every page be fully static, use a route segment instead of a query string — /blog/page/2 — and enumerate them:
// 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.
Keep the first page server-rendered, and append with a Server Action:
// 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.
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.?page=abc or ?page=-1 must not reach skip.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.searchParams in a Server Component.take + skip, always explicit, always sorted, and clamp the input.searchParams read in <Suspense> to keep a static shell./blog/page/2) if you want every page prerendered.