React

Paginazione con searchParams nell’App Router

La paginazione legge page da searchParams e resta nell’URL: codice, differenza tra offset e keyset, e cosa mettere in cache.

In sintesi

  • `page` arriva da `searchParams`, che in Next 15 è una Promise e va atteso.
  • La paginazione con `` tiene l’URL condivisibile e indicizzabile.
  • Offset scarta righe, keyset no: la scelta dipende dal volume, non dal gusto.

La paginazione nell’App Router legge page da searchParams, non dallo stato del client: così l’URL è condivisibile e la pagina resta indicizzabile. In Next 15 searchParams è una Promise e va atteso.

La firma della pagina

// 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 ;
}

Il cast va protetto. Un valore non numerico produce un offset invalido, e il database risponde con un errore invece che con la prima pagina. Number.isFinite e un clamp a 1 chiudono il caso senza eccezioni.

import Link from "next/link";


  Pagina successiva

La paginazione si fa con un link, non con un bottone che cambia stato. Il prefetch di dieci link di paginazione è traffico sprecato: prefetch={false} sui numeri tiene la navigazione veloce senza scaricare pagine che nessuno aprirà.

ApproccioURL condivisibileIndicizzabileCosto
searchParams più LinkSìSìUna richiesta per pagina
Stato del clientNoNoNessuna
Scroll infinitoSolo la prima paginaParzialeMolte richieste

Offset o keyset

OFFSET 120 LIMIT 12 rallenta quando la tabella cresce, perché il database legge e scarta le righe precedenti. Con un ordinamento stabile su createdAt, id la keyset pagination chiede WHERE (createdAt, id) < (?, ?) e il costo resta costante.

  • Offset: semplice, numeri di pagina visibili, adatto fino a qualche migliaio di righe.
  • Keyset: veloce a ogni profondità, ma niente salto diretto a pagina 7.
  • Conteggio totale: una query in più, da mettere in cache con una scadenza.

Sul catalogo EneUI, con 111.985 componenti e 1.556 blocchi misurati il 1 novembre 2026 su ui.eneawork.it, una griglia da 24 elementi produce oltre 4.600 pagine: il conteggio totale va in cache, non ricalcolato a ogni visita.

Componenti per la griglia e i controlli

Tre record: i componenti data table per la variante con colonne, il blocco data-table già composto, e i blocchi tabella per le liste dense.

Il comportamento della cache attorno a una lista paginata è in i quattro livelli di cache; il rendering progressivo della prima pagina in streaming e Suspense. Il confronto con un altro framework è in Remix vs Next.js.

Il limite da mettere in conto

La paginazione con searchParams rende ogni pagina una URL distinta: utile per la SEO, costoso per la cache se generi 4.600 pagine. Indicizza le prime e metti noindex oltre una soglia che decidi tu.

Domande frequenti

searchParams è asincrono?

Sì, da Next 15 è una Promise. Va atteso con <code>await</code> sia nella pagina sia in <code>generateMetadata</code>. Leggerlo come oggetto sincrono produce un errore di tipo.

Perché non uso lo stato del client?

Perché perdi l’URL condivisibile e la pagina 2 non esiste per i crawler. Lo stato del client resta utile per filtri temporanei che non vuoi nell’indice.

Meglio offset o keyset?

Offset fino a qualche migliaio di righe, keyset oltre. La keyset richiede un ordinamento stabile e un cursore nell’URL, quindi niente salto a una pagina arbitraria.

Come cacheo il conteggio totale?

Con <code>unstable_cache</code> o una cache HTTP con scadenza, non con una query a ogni richiesta. Un conteggio che cambia di poche unità non vale il costo.

Devo indicizzare tutte le pagine?

No. Le prime tre o cinque bastano ai crawler e agli utenti. Oltre quella soglia <code>noindex, follow</code> evita migliaia di pagine quasi identiche nell’indice.