React
Pagination with searchParams in the App Router
Pagination reads page from searchParams and keeps it in the URL: the code, offset versus keyset, and what belongs in the cache.
Key points
- `page` comes from `searchParams`, which in Next 15 is a Promise that must be awaited.
- Pagination with `` keeps the URL shareable and indexable.
- Offset discards rows, keyset does not: the choice depends on volume, not taste.
Pagination in the App Router reads page from searchParams, not from client state: the URL stays shareable and the page stays indexable. In Next 15 searchParams is a Promise and must be awaited.
The page signature
// app/blog/page.tsx
export default async function BlogPage({
searchParams,
}: {
searchParams: Promise;
}) {
const { page } = await searchParams;
const parsed = Number(page ?? 1);
const current = Number.isFinite(parsed) ? Math.max(1, Math.trunc(parsed)) : 1;
const perPage = 12;
const posts = await getPosts({ skip: (current - 1) * perPage, take: perPage });
return ;
}
Guard the cast. A non-numeric value produces an invalid offset, and the database answers with an error instead of the first page. Number.isFinite plus a clamp to 1 closes the case with no exceptions.
Links, not state
import Link from "next/link";
Pagina successiva
Pagination belongs in a link, not in a button that changes state. Prefetching ten pagination links is wasted traffic: prefetch={false} on the numbers keeps navigation fast without downloading pages nobody will open.
| Approach | Shareable URL | Indexable | Cost |
|---|---|---|---|
| searchParams plus Link | Yes | Yes | One request per page |
| Client state | No | No | None |
| Infinite scroll | First page only | Partial | Many requests |
Offset or keyset
OFFSET 120 LIMIT 12 slows down as the table grows, because the database reads and discards the rows before it. With a stable sort on createdAt, id, keyset pagination asks WHERE (createdAt, id) < (?, ?) and the cost stays flat.
- Offset: simple, visible page numbers, fine up to a few thousand rows.
- Keyset: fast at any depth, but no direct jump to page 7.
- Total count: one extra query, worth caching with an expiry.
On the EneUI catalogue, with 111,985 components and 1,556 blocks read on 1 November 2026 from ui.eneawork.it, a 24-item grid produces over 4,600 pages: the total count belongs in a cache, not in a query on every visit.
Components for the grid and the controls
Three records: the data table components for the version with columns, the composed data-table block, and the table blocks for dense lists.
Cache behaviour around a paginated list is in the four cache levels; progressive rendering of the first page is in streaming and Suspense. The framework comparison is in Remix vs Next.js.
The limit to budget for
Pagination with searchParams gives every page a distinct URL: good for SEO, expensive for the cache when you generate 4,600 pages. Index the first few and add noindex past a threshold you choose.
Frequently asked questions
Is searchParams async?
Yes, since Next 15 it is a Promise. Await it with <code>await</code> in the page and in <code>generateMetadata</code>. Reading it as a synchronous object gives a type error.
Why not use client state?
You lose the shareable URL and page 2 does not exist for crawlers. Client state still helps for temporary filters you do not want indexed.
Offset or keyset?
Offset up to a few thousand rows, keyset beyond. Keyset needs a stable sort and a cursor in the URL, so no jumping to an arbitrary page.
How do I cache the total count?
With <code>unstable_cache</code> or an HTTP cache with an expiry, not with a query on every request. A count that moves by a few units is not worth the cost.
Should every page be indexed?
No. The first three or five are enough for crawlers and users. Past that threshold <code>noindex, follow</code> keeps thousands of near-identical pages out of the index.