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.

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.

ApproachShareable URLIndexableCost
searchParams plus LinkYesYesOne request per page
Client stateNoNoNone
Infinite scrollFirst page onlyPartialMany 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.