wandres.dev
ROUTING AVANZADO · redirects, paginación, rewrites

La prop page y la navegación

Cada página paginada recibe un objeto page con todo lo que necesita para pintarse y para orientar al lector: la rebanada de datos en page.data, el número actual y el último en currentPage y lastPage, y un conjunto de URLs listas para navegar hacia la anterior, la siguiente, la primera y la última. Cómo leerlas y cómo construir con ellas una barra de paginación honesta.

⏱ 16 min

Cuando paginate fabrica una ruta, le adjunta un objeto page que viaja dentro de props. Ese objeto es el compañero de viaje de cada página numerada: contiene la rebanada de datos que le toca mostrar, sabe qué número de página es y cuántas hay en total, y —lo más útil para la navegación— trae ya calculadas las URLs de sus vecinas. No tienes que deducir cuál es la página siguiente ni componer su dirección a mano: page te la da hecha, y también te avisa, dejándola indefinida, cuando no existe. Toda la interfaz de paginación —el anterior, el siguiente, los números, el salto al principio y al final— se construye leyendo las propiedades de este objeto.

🎯 Al terminar esta lección sabrás
  • Leer la rebanada de datos y su contexto en page.data, page.currentPage y page.lastPage.
  • Usar page.url.prev y page.url.next para enlazar con las páginas vecinas.
  • Saltar a los extremos con page.url.first y page.url.last.
  • Construir una barra de navegación que oculte los enlaces que no aplican.

El objeto page: la rebanada y su contexto

Lo primero que ofrece page es lo que la página debe mostrar y en qué punto del conjunto se encuentra. page.data es el array de elementos de esta página —no la colección entera, solo su tramo—, y a su alrededor hay un puñado de números que sitúan ese tramo dentro del todo.

---
// src/pages/blog/[page].astro
const { page } = Astro.props;
---
<h1>Artículos</h1>
<p>Página {page.currentPage} de {page.lastPage}</p>
<p>Mostrando {page.start + 1} a {page.end + 1} de {page.total}</p>

<ul>
  {page.data.map((post) => (
    <li><a href={`/blog/${post.id}`}>{post.data.title}</a></li>
  ))}
</ul>

Cada propiedad tiene un papel claro. page.data es la rebanada que recorres para pintar la lista. page.currentPage es el número de esta página, empezando en uno; page.lastPage es el número de la última, es decir, cuántas páginas hay en total. Y para el clásico rótulo de mostrando tantos de tantos, page.start y page.end son los índices —de base cero— del primer y último elemento de esta página dentro del conjunto, y page.total es el número de elementos de toda la colección. Con esos cinco datos tienes resuelto todo el texto informativo de la paginación sin un solo cálculo propio.

Conviene mirar dos casos límite. Cuando la colección cabe entera en una sola página, page.lastPage vale uno y no hay vecinas: toda la navegación se apagará sola, como veremos enseguida. Y cuando page.total es cero, paginate genera de todos modos una página vacía, así que tu plantilla debe contemplar el caso de una lista sin elementos y no dar por hecho que page.data siempre trae algo que recorrer.

ℹ️
page.data es solo el tramo, no la colección

El error de principiante es tratar page.data como si fuera la colección completa. No lo es: es exactamente la porción que corresponde a esta página según el pageSize que fijaste. Si paginaste de diez en diez, page.data tiene como mucho diez elementos. La colección entera vivía en el getStaticPaths, donde la troceaste; a partir de ahí, cada página solo conoce su parte. Esa es justamente la idea de paginar: que ninguna página cargue con más de lo suyo.

Las URLs de navegación: next, prev, first y last

La joya de page es su propiedad url, un objeto con las direcciones de las páginas relacionadas ya construidas. No compones cadenas ni sumas números a la URL actual: las lees. Y hay una convención de diseño que lo hace especialmente cómodo: cuando una vecina no existe, su URL vale indefinido en lugar de apuntar a una página inválida.

  • page.url.current es la dirección de esta misma página, útil para la etiqueta canónica del head.
  • page.url.prev es la de la página anterior, o indefinido si estás en la primera.
  • page.url.next es la de la siguiente, o indefinido si estás en la última.
  • page.url.first es la de la primera página, indefinida si ya estás en ella.
  • page.url.last es la de la última, indefinida si ya estás en ella.

Esa semántica del indefinido no es un descuido: es lo que te permite construir una barra de navegación correcta sin escribir tú la lógica de los bordes. En la primera página no hay anterior ni primera a la que saltar, y Astro te lo comunica dejando esas URLs vacías; en la última ocurre lo simétrico con siguiente y última. Tu plantilla solo tiene que pintar el enlace cuando la URL existe.

flowchart TD
PG[objeto page en props] --> D[page data la rebanada]
PG --> M[page currentPage y page lastPage]
PG --> U[page url]
U --> FI[url first salta al principio]
U --> PV[url prev puede faltar]
U --> CU[url current para el canonical]
U --> NE[url next puede faltar]
U --> LA[url last salta al final]
style PG fill:#89b4fa,color:#11111b
style D fill:#a6e3a1,color:#11111b
style PV fill:#f9e2af,color:#11111b
style NE fill:#f9e2af,color:#11111b

Construir la UI: enlaces que se ocultan solos

Con esas piezas, la barra de paginación se escribe casi sola. La regla es uniforme: pinta cada enlace únicamente si su URL está definida. Como los extremos vienen indefinidos justo cuando no aplican, la condición page.url.prev es a la vez la pregunta hay página anterior y la fuente de su dirección.

---
const { page } = Astro.props;
---
<nav aria-label="Paginación">
  {page.url.first && <a href={page.url.first}>« Primera</a>}
  {page.url.prev && <a href={page.url.prev}>‹ Anterior</a>}

  <span>Página {page.currentPage} de {page.lastPage}</span>

  {page.url.next && <a href={page.url.next}>Siguiente ›</a>}
  {page.url.last && <a href={page.url.last}>Última »</a>}
</nav>

Ese patrón del y lógico —page.url.prev && ...— es el gesto idiomático: si la URL es indefinida, la expresión se corta y no se pinta nada; si existe, se renderiza el enlace con ella. El resultado es una navegación que se adapta a su posición sin un solo if explícito sobre currentPage: en la primera página desaparecen primera y anterior, en la última desaparecen siguiente y última, y en las intermedias aparecen las cuatro. Dejaste que la ausencia de datos, y no una comparación de números, gobierne la interfaz.

Para una paginación numerada —los botones con cada número de página— sí necesitas los enteros: recorres de uno a page.lastPage y compones cada URL, resaltando el que coincide con page.currentPage.

---
const { page } = Astro.props;
const numeros = Array.from({ length: page.lastPage }, (_, i) => i + 1);
---
<nav aria-label="Números de página">
  {numeros.map((n) => (
    <a
      href={n === 1 ? '/blog' : `/blog/${n}`}
      aria-current={n === page.currentPage ? 'page' : undefined}
    >{n}</a>
  ))}
</nav>

Ese recorrido de uno a page.lastPage materializa la tira de números, y aria-current marca el actual comparándolo con page.currentPage para que lectores de pantalla y estilos lo distingan sin un atributo extra. Con las flechas de los extremos y esta tira numerada, la barra de paginación queda completa.

Y para el SEO, page.url.current alimenta la etiqueta canónica, mientras que page.url.prev y page.url.next pueden volcarse en las relaciones rel prev y rel next del head para que los buscadores entiendan la serie.

⚠️
No calcules los bordes a mano

Es tentador escribir la lógica de extremos tú mismo: mostrar anterior solo si page.currentPage es mayor que uno, mostrar siguiente solo si es menor que page.lastPage. Funciona, pero duplica una información que page.url ya te da con más fiabilidad. Si mañana cambias dónde vive la primera página —de /blog/1 a /blog—, tus comparaciones numéricas siguen dando el número correcto pero podrían componer una URL incorrecta; en cambio page.url.prev siempre apunta al sitio real, porque lo calculó Astro conociendo la forma de las rutas. Confía en las URLs provistas antes que en reconstruirlas.

💡
Cada página paginada es una URL indexable

Es fácil pensar en la paginación como una sola página con estados, pero para los buscadores /blog/2 es tan real e indexable como /blog. De ahí que cada una necesite su propia etiqueta canónica apuntándose a sí misma —con page.url.current— y no todas al índice: si las canonicalizas todas hacia /blog, le dices al buscador que las profundas no existen y su contenido desaparece del rastreo. La serie rel prev y rel next completa el mensaje, revelando el orden de lectura. Tratar cada página como el recurso autónomo que es evita que la mitad de tu contenido se vuelva invisible.

🍰

page.data

La rebanada de esta pagina, no la coleccion. Tiene como mucho pageSize elementos.

🧭

currentPage y lastPage

El numero de esta pagina y el de la ultima. Con ellos pintas el rotulo y la barra numerada.

🔗

url prev y next

Las direcciones de las vecinas, o indefinido en los extremos. La ausencia oculta el enlace sola.

⏮️

url first y last

El salto directo al principio y al final, indefinidos cuando ya estas en ese extremo.

El indefinido en los extremos es la interfaz diciendo la verdad sobre sí misma

Detrás de la decisión de que page.url.prev valga indefinido en la primera página hay una elección de diseño más honda de lo que parece, y aprender a leerla te hace mejor programador en general. Astro podría haber ofrecido, en su lugar, un booleano hasPrev junto a un prevUrl que apuntara a cualquier sitio, o podría haber devuelto la URL de la propia página cuando no hay anterior, o un enlace roto. Cualquiera de esas opciones te obligaría a recordar comprobar la condición por separado, y el día que lo olvidaras pintarías un enlace que miente. Al colapsar existencia y valor en una sola propiedad —o hay URL, o no hay nada—, el diseño hace que el estado imposible sea inexpresable: no puedes pintar por accidente un anterior en la primera página, porque no hay URL que pintar. Esto es un principio profundo del buen diseño de interfaces de datos: haz que los estados inválidos no se puedan representar. Cuando modelas la ausencia como ausencia real —un indefinido, un tipo opcional— y no como un valor centinela que hay que recordar interpretar, trasladas la vigilancia del programador, que falla, al sistema de tipos y al flujo del lenguaje, que no. La expresión page.url.prev && enlace no es un truco de sintaxis; es la forma en que ese principio se manifiesta en tu plantilla: la ausencia de dato produce, por construcción, la ausencia de interfaz. Y esa correspondencia entre lo que existe en los datos y lo que aparece en la pantalla es, en el fondo, lo que significa que una interfaz sea honesta. Una barra de paginación bien hecha no decide ocultar sus extremos con una regla que tú vigilas; simplemente refleja un vacío que ya estaba en los datos. Interioriza esto y empezarás a ver que muchos errores de interfaz no son fallos de lógica, sino modelos de datos que permitían representar algo que no debería existir.

⚔️ Pinta una barra de paginación honesta
  1. En una página [page].astro, muestra el rótulo Página X de Y leyendo page.currentPage y page.lastPage, y la lista con page.data.
  2. Añade los enlaces Anterior y Siguiente usando page.url.prev y page.url.next con el patrón del y lógico; visita la primera y la última página y comprueba que cada enlace desaparece cuando no aplica.
  3. Suma los saltos Primera y Última con page.url.first y page.url.last, y verifica que se ocultan en sus respectivos extremos.
  4. Vuelca page.url.current en la etiqueta canónica del head y razona por qué cada página paginada necesita su propio canónico.