Crear un Componente de paginación personalizado en Laravel Inertia y Tailwind 4

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 trae un componente de paginación “listo para usar”, pero sí te da todas las piezas necesarias para construir una solución limpia, reutilizable y moderna.

En esta guía te muestro cómo implementar paginación en Laravel Inertia, primero de forma clásica y luego dando el salto a Infinite Scroll, usando la API 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 envías a Inertia, el framework serializa automáticamente la información necesaria para el frontend.

Un paginado típico incluye:

  • data: los registros de la página actual
  • links: los enlaces de navegación
  • meta: información adicional (página actual, total, etc.)

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 o React.

En Inertia, no tenemos un componente de paginación para usar desde el cliente (Vue o React); así que, tenemos que crear uno; recordemos que nos quedamos en esta guía en el uso de los mensajes flash en Laravel Inertia.

Backend: preparar paginación en Laravel

En el backend no hay mucho misterio, pero sí buenas prácticas:

  • Paginar siempre en el servidor
  • Evitar traer colecciones completas
  • Mantener consistencia en filtros y orden

Un ejemplo más realista:

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: evita que se pierdan al cambiar de página.

Desde el listado paginado, debemos el apartado de links en los cuales se encuentran todos los enlaces para la paginación en un array:

Estructura del prop paginado

Frontend con Vue 3 + Inertia

Recibir los datos paginados

En tu página:

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

Y ya puedes usar categories.data y categories.links.

Iterar correctamente los links

El array links ya viene listo para renderizar, así que basta con iterarlo:

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

Pero aquí aparece el primer problema real…

Evitar enlaces a la página actual

Cuando active === true, no tiene sentido renderizar un enlace que navegue a sí mismo. En mi caso, lo resolví separando <Link> y <span>:

<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, pero se puede limpiar mucho más.

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

Desde Vue, tenemos una etiqueta llamada "Component" con la cual podemos renderizar componentes de manera dinámica en base a una condición evaluada en con el prop de 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>

Vue tiene el componente <Component> que permite renderizar dinámicamente HTML o componentes según una condición.

Con este componente en Vue, podemos renderizar ya sea componente de Vue o HTML directamente desde la definición del mismo:

<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"
/>

Y con esto, tenemos el mismo resultado que antes, pero queda más limpia la sintaxis, como puedes ver, podemos agregar Tailwind 4 en cualquier momento.

Ahora, para poder reutilizar el componente de manera global, creamos un nuevo componente que reciba como parámetro el listado paginado mediante un prop y maquetado con Tailwind 4:

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 desactivar la página actual y evitar dejar un enlace que navegue a sí mismo, podemos hacer un condicional y colocar un SPAN cuando se utilice la página actual:

<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 agregar algo de estilo para indicar que es un enlace desactivado, aunque realmente es una etiqueta SPAN:

<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>
<script>

Cuando ya tienes esto funcionando, lo lógico es reutilizarlo. Yo suelo crear un componente compartido.

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>

Luego lo usas así:

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

Y listo. Paginación limpia, consistente y reutilizable.

Desde el listado de categorías, usamos este componente:

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 obtendremos:

http://localhost/category?page=1

 

Paginación personalizada

Infinite Scroll en Laravel Inertia (oficial)

Cuando la paginación clásica se queda corta (feeds, chats, grids), Infinite Scroll es la mejor opción.

Cuándo usar Infinite Scroll

  • Listados largos
  • Feeds tipo redes sociales
  • Chats
  • Galerías o grids
  • Productos relacionados

No lo uso para todo, pero cuando encaja, la experiencia de usuario mejora muchísimo.

Backend: Inertia::scroll()

Inertia ofrece un método específico para esto:

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

Este método:

Configura automáticamente el merge de datos

  • Normaliza la metadata
  • Funciona con paginate, simplePaginate, cursorPaginate
  • Funciona con API Resources
  • Frontend: <InfiniteScroll />

En Vue:

<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 usa Intersection Observer y va cargando páginas sin reemplazar el contenido.

Buffer, URL sync y reset

Puedes controlar cuándo se cargan los datos:

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

También puedes evitar que la URL cambie:

<InfiniteScroll data="users" preserve-url>

Y cuando aplicas filtros, es importante resetear:

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

Esto evita que los nuevos resultados se mezclen con los anteriores.

Modos avanzados

Solo siguiente página:

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

Modo reverse (ideal para chats):

<InfiniteScroll data="messages" reverse />

También puedes activar modo manual y controlar tú cuándo cargar más contenido.

Paginación clásica vs Infinite Scroll

  • Caso              Mejor opción
  • Panel admin    Paginación clásica
  • Feed social    Infinite Scroll
  • Chat    Infinite Scroll (reverse)
  • SEO estricto    Paginación clásica
  • UX moderna    Infinite Scroll

Preguntas frecuentes

  • ¿Infinite Scroll afecta al SEO?
    • No, Inertia mantiene sincronizada la URL con ?page=.
  • ¿Puedo combinar paginación e infinite scroll?
    • Sí, en diferentes secciones del mismo proyecto.
  • ¿Cuál es más fácil de mantener?
    • La paginación clásica. Infinite Scroll es más potente, pero también más compleja.

Conclusión

La paginación en Laravel Inertia no es un problema: es una oportunidad para construir una experiencia sólida y reutilizable. Empieza con paginación clásica, crea tus componentes y, cuando el proyecto lo pida, da el salto a Infinite Scroll usando la API oficial.

Es exactamente el camino que yo recomiendo y el que mejor escala en proyectos reales.

Siguiente paso, conozcamos el uso de otro componente que a diferencia de este ya existe en Inertia, la Barra de progreso y spinner.

Aprende cómo implementar paginación en Laravel Inertia con Vue 3 y Tailwind 4: componente reutilizable, buenas prácticas y infinite scroll oficial paso a paso.

Acepto recibir anuncios de interes sobre este Blog.

Andrés Cruz

EN In english