Laravel Inertia Pagination con Vue 3: componente custom y Infinite Scroll oficial paso a paso

- Andrés Cruz - EN In english

Video thumbnail

Cuando trabajas con Laravel + Inertia + Vue 3, tarde o temprano llegas al mismo punto: tienes un listado grande y necesitas paginación. Inertia no incluye un componente de paginación listo para usar, pero sí te da todas las piezas necesarias para construir una solución limpia, reutilizable y completamente adaptada a tu proyecto.

En esta guía te muestro cómo implementar paginación personalizada en Laravel Inertia, primero de forma clásica con un componente Pagination.vue propio estilado con Tailwind 4, y luego dando el salto al Infinite Scroll oficial de Inertia. Es exactamente el flujo que yo mismo seguí al construir listados reales en paneles de administración y dashboards.

¿Qué devuelve Laravel al paginar con Inertia?

Cuando usas cualquiera de los métodos de paginación de Laravel (paginate, simplePaginate, cursorPaginate) y los pasas a Inertia, el framework serializa automáticamente la información necesaria para el frontend. La diferencia entre ellos importa: paginate devuelve el total de registros y permite navegar a cualquier página; simplePaginate es más eficiente en tablas grandes porque no ejecuta el COUNT(*); y cursorPaginate es ideal para conjuntos de datos muy grandes o feeds donde el rendimiento es crítico.

Un paginado típico incluye:

  • data: los registros de la página actual
  • links: los enlaces de navegación (anterior, páginas numeradas, siguiente)
  • meta: información adicional como la página actual, el total de registros y el número de páginas

Por ejemplo, desde un controlador:

public function index()
{
   return Inertia::render('Dashboard/Category/Index', [
       'categories' => Category::paginate(10)
   ]);
}

En el frontend recibes algo como:

categories: {
 data: [...],
 links: [
   { url: null, label: '« Previous', active: false },
   { url: 'http://app.test?page=1', label: '1', active: true },
   { url: 'http://app.test?page=2', label: '2', active: false },
   ...
 ]
}

Aquí es donde empieza el verdadero trabajo en Vue. El array links ya viene formateado y listo para renderizar; solo hay que saber cómo iterarlo correctamente.

Recuerda que en Inertia no existe un componente de paginación listo del lado del cliente (Vue o React), así que tenemos que construir el nuestro. En esta guía continuamos desde donde lo dejamos en el capítulo sobre los mensajes flash en Laravel Inertia.

Backend: preparar la paginación en Laravel

En el backend no hay mucho misterio, pero sí hay buenas prácticas que marcan la diferencia a largo plazo:

  • Paginar siempre en el servidor, nunca traer colecciones completas al frontend
  • Evitar cargar relaciones innecesarias con with() si no se usan en la vista
  • Mantener consistencia en los filtros y el orden para no romper la URL al cambiar de página

Un ejemplo más realista e idiomático de Laravel:

public function index(Request $request)
{
   $categories = Category::orderBy('created_at', 'desc')
       ->paginate(10)
       ->withQueryString();
   return Inertia::render('Dashboard/Category/Index', [
       'categories' => $categories
   ]);
}
  • withQueryString() es clave si usas filtros o búsquedas: preserva todos los parámetros de la query string al cambiar de página, evitando que se pierdan los criterios de búsqueda activos.

Del objeto paginado que devuelve Laravel, el apartado que más nos interesa es links, que contiene un array con todos los enlaces necesarios para la navegación:

Estructura del prop paginado en Inertia

Frontend con Vue 3 + Inertia: recibir los datos paginados

En tu página de Vue, recibes el resultado paginado como un prop de tipo Object:

<script setup>
defineProps({
 categories: Object
})
</script>

A partir de ahí ya puedes acceder a categories.data para renderizar los registros y a categories.links para construir la navegación.

Iterar correctamente el array de links

El array links ya viene preparado para renderizar directamente, así que basta con iterarlo con v-for:

<Link
 v-for="l in categories.links"
 :key="l.label"
 :href="l.url"
 v-html="l.label"
/>

Pero aquí aparece el primer problema real: los enlaces "Anterior" y "Siguiente" deshabilitados tienen url: null, y el enlace de la página activa llevaría al usuario a la misma página donde ya está. Necesitamos manejar ambos casos.

Evitar enlaces a la página actual

Cuando active === true, no tiene sentido renderizar un <Link> que navegue a sí mismo. Además, perjudica la accesibilidad y puede generar peticiones innecesarias. En mi caso, lo resolví separando <Link> y <span> con un v-if/v-else:

<template v-for="l in categories.links" :key="l.label">
 <Link
   v-if="!l.active"
   class="px-2 py-1"
   :href="l.url"
   v-html="l.label"
 />
 <span
   v-else
   class="px-2 py-1 cursor-pointer text-gray-500"
   v-html="l.label"
 />
</template>

Funciona bien, pero la sintaxis con el v-if/v-else repetido en cada template se puede simplificar bastante con el componente dinámico de Vue.

Render dinámico con <component>: crear un componente Pagination.vue reutilizable con Tailwind 4

Vue ofrece una etiqueta especial llamada <component> que permite renderizar de forma dinámica un componente de Vue o un elemento HTML nativo según una condición evaluada en el prop :is:

import Foo from './Foo.vue'
import Bar from './Bar.vue'

export default {
  components: { Foo, Bar },
  data() {
    return {
      view: 'Foo'
    }
  }
}
</script>

<template>
  <component :is="view" />
</template>

<component :is="..."> es el mecanismo de Vue para renderizar dinámicamente un componente registrado o un elemento HTML nativo según una condición evaluada en tiempo de ejecución.

Aplicado a nuestra paginación, podemos decidir entre renderizar el componente Link de Inertia o un <span> nativo en una sola línea:

<Component
 v-for="l in categories.links"
 :key="l.label"
 :is="!l.active ? 'Link' : 'span'"
 class="px-2 py-1"
 :class="!l.active ? '' : 'text-gray-500 cursor-pointer'"
 :href="l.url"
 v-html="l.label"
/>

El resultado es idéntico al anterior, pero la sintaxis queda mucho más limpia y fácil de mantener. A partir de aquí podemos añadir las clases de Tailwind 4 que queramos sin tocar la lógica.

Para poder reutilizar este componente en cualquier parte del proyecto, lo encapsulamos en un archivo independiente que recibe el listado paginado mediante un prop:

resources/js/Shared/Pagination.vue

<template>
     <!-- <Link class="px-2 py-1" v-if="!l.active" :href="l.url">{{ l.label }}</Link>
            <span class="px-2 py-1 cursor-pointer text-gray-500" v-else>{{ l.label }}</span> -->
     <template v-for="l in links" v-bind:key="l">
          <Component v-html="`${l.label}`" class="px-2 py-1" :is="!l.active ? 'Link' : 'span'"
          :href="l.url == null ? '#' : l.url" :class="!l.active ? '' : 'cursor-pointer text-gray-500'"
          />
     </template>
</template>
<script>
import { Link } from '@inertiajs/vue3';
export default {
     components: {
          Link
     },
     props: {
          links: Array
     }
}
</script>

Para que la página activa no se comporte como un enlace clicable, renderizamos un <span> en su lugar. Esto mejora la accesibilidad y evita peticiones innecesarias al servidor:

<template v-for="l in categories.links" :key="l">
     <Link v-if="!l.active" class="px-2 py-1" :href="l.url" v-html="l.label"/>
     <span v-else class="px-2 py-1" v-html="l.label" />
</template>

Y le añadimos estilos para dejar claro visualmente que ese elemento está desactivado:

<template v-for="l in categories.links" :key="l">
     <Link v-if="!l.active" class="px-2 py-1" :href="l.url" v-html="l.label"/>
     <span v-else class="px-2 py-1 cursor-pointer text-gray-500" v-html="l.label" />
</template>

Cuando ya tienes esto funcionando de forma básica, lo lógico es encapsularlo en un componente compartido para no repetir el mismo código en cada página que lo necesite.

resources/js/Shared/Pagination.vue

<script setup>
import { Link } from '@inertiajs/vue3'
defineProps({
 links: Array
})
</script>
<template>
 <template v-for="l in links" :key="l.label">
   <Component
     :is="!l.active ? 'Link' : 'span'"
     class="px-2 py-1"
     :class="!l.active ? '' : 'cursor-pointer text-gray-500'"
     :href="l.url ?? '#'"
     v-html="l.label"
   />
 </template>
</template>

Esta versión usa <script setup>, que es la forma recomendada en Vue 3 con la Composition API. El operador ?? en :href="l.url ?? '#'" garantiza que los botones deshabilitados no produzcan un error de prop vacío.

Para usarlo, basta con importarlo y pasarle el prop links:

<Pagination :links="categories.links" />

Paginación limpia, consistente y reutilizable en cualquier listado del proyecto.

Desde el listado de categorías, el uso quedaría así:

resources/js/Pages/Dashboard/Category/Index.vue

***
          <pagination :links="categories.links" />
     </app-layout>
</template>
***
import Pagination from '@/Shared/Pagination.vue'

export default {
     components:{
          AppLayout,
          Link,
          Pagination
     },
// ***

Y el resultado en el navegador, con la URL sincronizada:

http://localhost/category?page=1

 

Paginación personalizada con Tailwind 4 en Laravel Inertia

Infinite Scroll en Laravel Inertia (API oficial)

La paginación clásica es ideal para paneles de administración, pero cuando trabajas con feeds, chats o galerías, el Infinite Scroll ofrece una experiencia de usuario considerablemente más fluida. Lo bueno es que Inertia incluye soporte oficial para esto, sin necesidad de instalar librerías de terceros.

¿Cuándo usar Infinite Scroll?

  • Listados largos donde mostrar la cantidad total no aporta valor
  • Feeds tipo redes sociales (posts, notificaciones, actividad)
  • Chats (especialmente en modo reverse)
  • Galerías o grids de imágenes y productos
  • Secciones de productos relacionados o recomendaciones

No lo uso para todo, pero cuando encaja, la mejora en experiencia de usuario es evidente.

Backend: Inertia::scroll()

Inertia ofrece un método específico en el servidor para configurar automáticamente el comportamiento de scroll:

Route::get('/users', function () {
   return Inertia::render('Users/Index', [
       'users' => Inertia::scroll(fn () => User::paginate())
   ]);
});

El método Inertia::scroll():

  • Configura automáticamente el merge de datos entre páginas, de forma que los nuevos registros se añaden al array existente en lugar de reemplazarlo
  • Normaliza la metadata necesaria para que el frontend sepa cuándo hay más páginas disponibles
  • Es compatible con paginate, simplePaginate y cursorPaginate
  • Es compatible con API Resources de Laravel

En Vue, el componente <InfiniteScroll /> se importa directamente de @inertiajs/vue3:

<script setup>
import { InfiniteScroll } from '@inertiajs/vue3'
defineProps(['users'])
</script>
<template>
 <InfiniteScroll data="users">
   <div v-for="user in users.data" :key="user.id">
     {{ user.name }}
   </div>
 </InfiniteScroll>
</template>

Internamente, <InfiniteScroll> usa la Intersection Observer API del navegador para detectar cuando el usuario llega al final del contenido visible y, en ese momento, solicita la siguiente página sin reemplazar el contenido ya cargado.

Buffer, URL sync y reset

Puedes controlar el umbral de activación del scroll con el prop :buffer. El valor indica en píxeles cuánto antes del final del contenido se dispara la carga:

<InfiniteScroll data="users" :buffer="500">

Si prefieres que la URL no cambie al cargar nuevas páginas (útil en ciertos casos de UX), usa preserve-url:

<InfiniteScroll data="users" preserve-url>

Y cuando el usuario aplica un filtro, es fundamental resetear el listado para que los nuevos resultados no se mezclen con los anteriores:

router.visit(route('users'), {
 data: { filter: { role } },
 only: ['users'],
 reset: ['users'],
})

La opción reset indica a Inertia que limpie el prop users antes de aplicar los nuevos datos, evitando que el feed acumule resultados de búsquedas anteriores.

Modos avanzados de <InfiniteScroll>

Solo cargar la siguiente página (sin botón de página anterior):

<InfiniteScroll data="users" only-next />

Modo reverse (ideal para interfaces de chat, donde los mensajes más recientes aparecen abajo):

<InfiniteScroll data="messages" reverse />

También puedes desactivar la carga automática y controlar manualmente cuándo cargar más contenido, útil si prefieres mostrar un botón de "Cargar más" en lugar del scroll automático.

Paginación clásica vs Infinite Scroll: ¿cuándo usar cada una?

Caso de usoMejor opción
Panel de administraciónPaginación clásica
Feed social (posts, notificaciones)Infinite Scroll
Chat / mensajesInfinite Scroll (reverse)
SEO estricto (Google indexa el contenido)Paginación clásica
UX moderna / experiencia fluidaInfinite Scroll
Tablas con filtros y búsquedaPaginación clásica

Preguntas frecuentes

  • ¿Infinite Scroll afecta al SEO?
    • En principio no, porque Inertia mantiene la URL sincronizada con el parámetro ?page=. Sin embargo, si el SEO es una prioridad crítica y necesitas que Google indexe cada página de resultados, la paginación clásica sigue siendo la opción más predecible.
  • ¿Puedo combinar paginación clásica e Infinite Scroll en el mismo proyecto?
    • Sí, perfectamente. Puedes usar paginación clásica en el panel de administración e Infinite Scroll en la sección pública o en el feed de la aplicación. No son excluyentes.
  • ¿Cuál es más fácil de mantener?
    • La paginación clásica, sin duda. Infinite Scroll es más potente y elegante visualmente, pero implica gestionar el estado del merge, los resets al filtrar y los edge cases del scroll. Empieza por la clásica y migra cuando tengas una necesidad real.

Conclusión

La paginación en Laravel Inertia no es un problema: es una oportunidad para construir una experiencia sólida, reutilizable y adaptada a cada contexto. Empieza con la paginación clásica creando tu propio componente Pagination.vue, añade los estilos de Tailwind 4 que quieras y, cuando el proyecto lo pida, da el salto al Infinite Scroll usando la API oficial de Inertia.

Es exactamente el camino que yo recomiendo y el que mejor escala en proyectos reales: evolucionar la solución de forma incremental, sin sobreingeniería desde el principio.

El siguiente paso es conocer otro componente que, a diferencia del que acabamos de construir, ya viene incluido en Inertia: la Barra de progreso y spinner.

Aprende a crear un componente de paginación personalizado en Laravel Inertia con Vue 3 y Tailwind 4. Incluye ejemplo de Pagination.vue reutilizable e Infinite Scroll con la API oficial paso a paso.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

Acepto recibir anuncios de interes sobre este Blog.