Complemento del blog · Paginación

La Paginación: partir el listado sin perder SEO

Cuando los artículos crecen, una sola página interminable se vuelve lenta y difícil de recorrer. La paginación la parte en tramos navegables —página 1 en /blog, el resto en /blog/pagina/<n>— con enlaces reales que el buscador entiende.

Esta página no es una ficha técnica: es el complemento entero, abierto y explicado. Qué problema resuelve, de qué piezas se compone, cómo se comporta en el teléfono, dónde encaja y, al final, cómo está construido —del criterio de diseño a la línea de código—.

Con un matiz propio que conviene marcar: aquí la paginación NO vive en la raíz del blog, porque /blog/<slug> ya la ocupan los artículos. La página 1 es /blog y las demás cuelgan de /blog/pagina/<n>. Se generan en build, son HTML estático y no dependen de JavaScript.

Definición

¿Qué es la paginación?

El mecanismo que parte un listado largo en varias páginas más cortas, con controles para moverse entre ellas: anterior, números y siguiente.

Paginar es dividir una lista —aquí, los artículos del blog— en tramos de tamaño fijo y darle a cada tramo su propia página y su URL. En vez de /blog con 50 entradas, tienes /blog (las primeras), /blog/pagina/2, /blog/pagina/3… Cada una carga un puñado y ofrece los controles para saltar a las demás.

No se inventó aquí: es el patrón clásico de cualquier listado extenso —resultados de búsqueda, catálogos, archivos de blog—. La alternativa (mostrarlo todo de golpe, o cargar al hacer scroll) tiene su lugar, pero la paginación numerada sigue siendo la más predecible para la persona y la más amable con el buscador.

Función e importancia

¿Para qué sirve?

Hace tres trabajos: aligera cada página (rendimiento), mantiene el listado navegable e indexable, y todo sin JavaScript porque se genera en build.

Su función es que un archivo que crece no se vuelva un lastre. Sin paginar, cada artículo nuevo hace la página del blog más pesada y más lenta —más HTML, más imágenes, peor Core Web Vitals—. Paginar mantiene cada página acotada y rápida, sin importar cuántos artículos haya en total.

Y lo hace sin sacrificar el SEO ni la usabilidad. Cada página es una URL real e indexable, los controles son enlaces (no scripts), y la relación entre páginas se declara con rel="prev"/"next". El lector siempre sabe cuánto hay y dónde está; el buscador, cómo encadenar la serie.

Páginas ligeras y rápidas

En vez de cargar 50 artículos (y 50 imágenes) de una sola vez, cada página trae un puñado. El HTML es más corto, las imágenes en pantalla son menos y el Largest Contentful Paint mejora. La paginación es, ante todo, una decisión de rendimiento.

Navegable e indexable

Los controles son enlaces reales (<a href>), no botones con JavaScript: funcionan sin script, se pueden abrir en otra pestaña y el buscador los rastrea. Con rel="prev"/"next" se entiende la continuidad de la serie. Cada página es una URL propia, indexable.

Generada en build, cero JS

Todas las páginas se crean en el build con getStaticPaths: HTML estático servido desde el edge, sin consultas en cada visita ni JavaScript de cliente. Más rápido y más barato de servir, y nada que se rompa si el script falla.

Anatomía

¿Qué lleva la paginación?

Cinco piezas: el enlace «Anteriores», los números de página, el enlace «Siguientes», la ruta partida (/blog + /blog/pagina/<n>) y el tamaño de página.

Cada pieza cumple un papel claro. Los extremos (anterior/siguiente) son el par mínimo de navegación; los números dan contexto y acceso directo; la ruta partida resuelve el dónde viven las páginas; y el tamaño de página es la perilla que decide cuántas hay. Visibles arriba, invisibles abajo (ruta y tamaño), pero todas necesarias.

Abajo, el ejemplo en vivo —réplica anotada del componente PaginationNav—. Cada punto numerado se desglosa en su tarjeta: qué resuelve y de qué dato sale. La página actual va marcada y sin enlace; los extremos se desactivan en la primera y la última.

1

Enlace «Anteriores»

El control que retrocede una página. Es un <a href> real con rel="prev": señal de continuidad para el buscador y navegación sin JavaScript. En la página 1 se desactiva (no hay anterior).

Dato rel="prev" · /blog o /blog/pagina/<n-1>

2

Números de página

La lista de páginas (1, 2, 3…). El número actual se marca con aria-current="page" y no enlaza; los demás llevan a su página. Da una idea clara de cuánto hay y dónde estás.

Dato aria-current="page" en la actual

3

Enlace «Siguientes»

El control que avanza una página, con rel="next". En la última página se desactiva. Junto con «Anteriores», es el par mínimo que toda paginación necesita.

Dato rel="next" · /blog/pagina/<n+1>

4

La ruta partida

La página 1 vive en /blog (canónica) y el resto en /blog/pagina/<n>. Se usa un subpath a propósito: la raíz /blog/<slug> ya la ocupa el catch-all de artículos, así que paginar en /blog/<n> chocaría.

Dato /blog · /blog/pagina/[page].astro

5

El tamaño de página

Cuántos artículos entran por página (PAGE_SIZE). Es la perilla única que decide el número de páginas: subirlo alarga cada página y reduce su número; bajarlo, al revés. Vive en lib/blog.ts, una sola fuente.

Dato PAGE_SIZE · lib/blog.ts

Variantes

Otros diseños y aplicaciones

La de esta plantilla es numerada con prev/next, pero la paginación cambia según el contenido: minimal, con elipsis, «cargar más», scroll infinito o sticky.

No hay un único modelo: hay una misma idea —partir un listado y dar controles para moverse— que cada caso ajusta. Pocas páginas piden solo prev/next; un archivo enorme, números con elipsis; un feed móvil, «cargar más»; una red social, scroll infinito (con red de seguridad).

Abajo, seis variantes —dos son configuraciones reales del componente (ocultando números o fijándolo) y el resto, extensiones que requieren más lógica o JavaScript—. Cada una con su réplica en vivo y el tipo de proyecto donde rinde mejor.

  • Numerada con prev/next (esta plantilla)

    Blog · Catálogo

    La del blog: «Anteriores», números de página y «Siguientes». Da contexto (cuántas páginas hay) y acceso directo a cualquiera. Es la más completa y la más amigable para SEO. Configuración real del componente PaginationNav.

  • Solo Anterior / Siguiente

    Minimal · Pocas páginas

    Sin números: solo los dos extremos. Más limpia cuando hay pocas páginas o el orden cronológico basta (como un «más antiguos / más nuevos»). Se logra ocultando la lista de números; los enlaces prev/next siguen ahí.

  • Numerada con elipsis

    Archivo grande

    Cuando hay muchas páginas (1 … 6 7 8 … 24), se colapsan las del medio con elipsis para no llenar la barra. Extensión propuesta: requiere una función que decida qué números mostrar alrededor de la actual y en los extremos.

  • «Cargar más» (botón)

    Feed · Móvil

    Un botón que añade la siguiente página al final sin recargar. Cómodo en móvil, pero necesita JavaScript y conviene dejar también enlaces reales para SEO. Extensión: el botón pide /blog/pagina/<n> y concatena resultados.

  • Scroll infinito

    Redes · Descubrimiento

    Carga más al llegar al fondo, sin clic. Engancha en feeds, pero rompe el «volver», complica indexar y marea en archivos largos. Si se usa, SIEMPRE con paginación real por debajo como malla de seguridad. Extensión con JS.

  • Barra sticky al pie

    Listados largos

    La misma paginación numerada, pero fija al borde inferior mientras se hace scroll por el listado. Útil en catálogos muy largos para no tener que llegar al final para cambiar de página. Es CSS por encima del mismo componente.

Responsive y móvil

Cómo se comporta en el teléfono

En móvil la barra no se aprieta: los controles se envuelven y se centran. Si el espacio es mínimo, puede dejar solo prev/next con un contador, y siempre con área táctil cómoda.

En escritorio la barra vive en una sola línea: «Anteriores», los números y «Siguientes». En el teléfono, esa línea no siempre cabe, así que la barra usa flex con wrap: los números saltan de línea y todo se centra, sin romperse ni desbordar. Es lo que ya hace el componente.

A partir de ahí, dos ajustes según el caso: en pantallas muy estrechas, ocultar los números y dejar solo los extremos con un «Página X de Y»; y, en todos los casos, asegurar que cada control mida al menos 44 px para tocarse cómodo. Abajo, cada patrón con su vista y su receta.

1 · Controles que se envuelven (default)

La barra es flex-wrap: wrap y se centra: si los números no caben en una línea, saltan a la siguiente sin desbordar. Es lo que hace el componente de fábrica, sin tocar nada.

CSS · barra que envuelve
/* MÓVIL · los controles se envuelven y se centran (default).
   La barra es flex con wrap: si los números no caben en una
   línea, saltan a la siguiente sin romper el layout. */

.pag       { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--sp-2); }
.pag__nums { display: flex; flex-wrap: wrap; gap: var(--sp-1); }

2 · Solo prev/next + contador (extensión)

En pantallas muy estrechas, los números estorban; basta con los dos extremos y un «Página X de Y». Se ocultan los números por CSS y el contador puede salir de atributos data-*, sin JavaScript.

CSS · compacta en móvil
/* MÓVIL · esconder números, dejar prev/next + contador (extensión).
   En pantallas muy estrechas, los números estorban; basta con
   los dos extremos y un "Página X de Y". */

@media (max-width: 480px) {
  .pag__nums { display: none; }
  .pag::after {
    content: 'Página ' attr(data-current) ' de ' attr(data-total);
    font-size: var(--text-sm); color: var(--c-muted);
  }
}

3 · Área táctil cómoda (≥44 px)

En el teléfono se toca con el dedo: cada número y cada enlace prev/next mide al menos 44 px de alto, el objetivo táctil recomendado. Evita toques fallidos y respeta las pautas de accesibilidad.

CSS · objetivo táctil 44px
/* Área táctil cómoda: cada control ≥ 44 px de alto en el teléfono.
   Se toca con el dedo, no con un cursor. */

@media (max-width: 768px) {
  .pag__num, .pag__edge { min-height: 44px; }
}

Posición

¿Dónde se coloca?

Al final del listado, dentro de la columna de contenido, debajo de las tarjetas. Una vez por página de listado —en /blog y en cada /blog/pagina/<n>—.

La paginación cierra el listado: va después de la rejilla de tarjetas, en la columna principal (no en el sidebar). Vive dentro del componente BlogListing, así que aparece igual en la página 1 (/blog) y en las siguientes (/blog/pagina/<n>) sin escribirla dos veces.

No se repite arriba ni se mete en el chrome: es un cierre de listado, no navegación global. Su relación con el resto es clara: comparte la rejilla y la tarjeta del listado, convive con el sidebar a su derecha y no aparece en las páginas de artículo (ahí el avance lo dan los relacionados).

Piezas cercanas: el sidebar (la columna de al lado), el listado del blog (donde aparece la paginación) y la anatomía del blog (el resto de complementos).

Implementación

Cómo está construida

Página 1 en /blog (un slice de la colección) y el resto en /blog/pagina/[page] (getStaticPaths genera 2..N). El componente PaginationNav arma los enlaces; PAGE_SIZE vive en lib/blog.ts.

El reparto es deliberado. La raíz /blog/<slug> ya la ocupa el catch-all de artículos, así que no se puede paginar en /blog/<n>. La solución: /blog es la página 1 (toma los primeros PAGE_SIZE de la colección) y /blog/pagina/[page].astro genera las páginas 2..N en build con getStaticPaths. Las dos comparten el mismo cuerpo (BlogListing), así que el listado se ve idéntico en todas.

Los enlaces los arma PaginationNav con una regla simple: la página 1 apunta a /blog y el resto a /blog/pagina/<n>, con rel="prev"/"next" en los extremos y aria-current en la actual. El tamaño de página es una constante única (PAGE_SIZE en lib/blog.ts), de la que se deriva el total: cambiarla reordena todo sin tocar las páginas. Cero JavaScript: HTML estático generado en build.

Astro · página 1 en /blog (slice de la colección)
---
// /blog/index.astro · la PÁGINA 1 = los primeros PAGE_SIZE artículos.
// El mismo cuerpo (BlogListing) se reutiliza en /blog y en /blog/pagina/<n>.
import { PAGE_SIZE, blogSidebarData } from '@lib/blog'

const total     = Math.max(1, Math.ceil(articulos.length / PAGE_SIZE))
const pageItems = articulos.slice(0, PAGE_SIZE)
const sidebar   = blogSidebarData(articulos)   // listas del sidebar (todas las entradas)
---

<BlogListing articulos={pageItems} current={1} total={total} sidebar={sidebar} />
Astro · páginas 2..N en /blog/pagina/[page] (getStaticPaths)
---
// /blog/pagina/[page].astro · genera las páginas 2..N en build.
import { getCollection } from 'astro:content'
import { PAGE_SIZE } from '@lib/blog'

export async function getStaticPaths() {
  const articulos = (await getCollection('articulos', ({ data }) => !data.draft))
    .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())

  const total = Math.ceil(articulos.length / PAGE_SIZE)

  // La página 1 es /blog (canónica); aquí solo 2..N.
  const paths = []
  for (let p = 2; p <= total; p++) {
    paths.push({
      params: { page: String(p) },
      props: {
        items: articulos.slice((p - 1) * PAGE_SIZE, p * PAGE_SIZE),
        current: p, total,
      },
    })
  }
  return paths
}
---
Astro · los enlaces (PaginationNav: /blog + /blog/pagina/<n>)
---
// PaginationNav.astro · la página 1 es /blog; el resto, /blog/pagina/<n>.
const url  = (p) => (p <= 1 ? '/blog' : `/blog/pagina/${p}`)
const prev = current > 1     ? url(current - 1) : null
const next = current < total ? url(current + 1) : null
---

<nav aria-label="Paginación de artículos">
  {prev && <a href={prev} rel="prev">← Anteriores</a>}
  {/* …números: la actual con aria-current, las demás enlazadas… */}
  {next && <a href={next} rel="next">Siguientes →</a>}
</nav>

En concreto: PAGE_SIZE y el cálculo de las listas viven en lib/blog.ts, una sola fuente. La página 1 hace articulos.slice(0, PAGE_SIZE); la ruta dinámica recorre 2..total y, por cada una, pasa su rebanada como props. El sidebar se calcula con TODAS las entradas (no con la página actual), así que es idéntico en cada página.

La accesibilidad y el SEO son de fábrica: el contenedor es un <nav aria-label>, los controles son <a href> reales con rel="prev"/"next", la página actual lleva aria-current="page" y los extremos se desactivan en la primera y la última. Todo se genera en build: sin consultas por visita y sin JavaScript de cliente.

Buenas prácticas

Qué hacer y qué evitar

La diferencia entre una paginación que ayuda y una que estorba cabe en un puñado de hábitos —empezando por usar enlaces reales—.

Ninguno de estos hábitos es capricho: salen de mirar dónde tropieza un listado cuando crece. Una paginación sana usa enlaces reales, declara prev/next, mantiene /blog como página 1 canónica y deriva todo de un único PAGE_SIZE. Una que estorba esconde la navegación tras JavaScript, choca con las rutas de artículos o deja la página actual enlazada a sí misma.

La buena noticia es que casi todo se sostiene solo cuando los controles son <a href> y el tamaño vive en una sola fuente. Abajo, lo que conviene y lo que conviene evitar, enfrentados.

Sí conviene

  • Usa <a href> reales para cada control (no botones con JS): navegables e indexables.
  • Marca rel="prev"/"next" en los extremos y aria-current en la página actual.
  • Mantén /blog como página 1 canónica; el resto en /blog/pagina/<n>.
  • Centraliza PAGE_SIZE en una sola fuente (lib/blog.ts) y deriva el total de ahí.
  • Cuida el área táctil (≥44 px) de cada número y de los enlaces prev/next.

Mejor evita

  • No pagines en /blog/<n>: choca con el catch-all de artículos (/blog/<slug>).
  • No uses scroll infinito como ÚNICA navegación: rompe el «volver» y dificulta indexar.
  • No dejes la página actual como enlace a sí misma: márcala con aria-current y sin href.
  • No escondas la paginación tras JavaScript: si el script no corre, no hay navegación.
  • No cambies PAGE_SIZE en cada página a mano: una sola fuente o se desincroniza el total.
¿Necesitas ayuda?