Laravel Inertia Pagination with Vue 3: Custom Component and Official Infinite Scroll – Step-by-Step

- Andrés Cruz - ES En español

Video thumbnail

When working with Laravel + Inertia + Vue 3, sooner or later you arrive at the same point: you have a large dataset and you need pagination. Inertia does not include an out-of-the-box pagination component, but it does give you all the necessary pieces to build a clean, reusable solution completely tailored to your project.

In this guide, I show you how to implement custom pagination in Laravel Inertia, first in a classic way with a custom Pagination.vue component styled with Tailwind 4, and then making the leap to Inertia's official Infinite Scroll. It is exactly the workflow I followed myself when building real lists in admin panels and dashboards.

What does Laravel return when paginating with Inertia?

When you use any of Laravel's pagination methods (paginate, simplePaginate, cursorPaginate) and pass them to Inertia, the framework automatically serializes the necessary information for the frontend. The difference between them matters: paginate returns the total count of records and allows navigating to any page; simplePaginate is more efficient for large tables because it does not execute a COUNT(*); and cursorPaginate is ideal for very large datasets or feeds where performance is critical.

A typical pagination object includes:

  • data: the records for the current page
  • links: the navigation links (previous, numbered pages, next)
  • meta: additional information such as the current page, total records, and total number of pages

For example, from a controller:

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

On the frontend, you receive something like:

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 },
   ...
 ]
}

This is where the real work in Vue begins. The links array arrives already formatted and ready to render; you just need to know how to iterate over it correctly.

Remember that in Inertia there is no ready-to-use client-side pagination component (Vue or React), so we have to build our own. In this guide, we continue from where we left off in the chapter on flash messages in Laravel Inertia.

Backend: preparing pagination in Laravel

On the backend there is not much mystery, but there are best practices that make a difference in the long run:

  • Always paginate on the server, never fetch full collections to the frontend
  • Avoid loading unnecessary relationships with with() if they are not used in the view
  • Maintain consistency in filters and sorting so as not to break the URL when changing pages

A more realistic and idiomatic Laravel example:

public function index(Request $request)
{
   $categories = Category::orderBy('created_at', 'desc')
       ->paginate(10)
       ->withQueryString();
   return Inertia::render('Dashboard/Category/Index', [
       'categories' => $categories
   ]);
}
  • withQueryString() is key if you use filters or searches: it preserves all query string parameters when changing pages, preventing active search criteria from being lost.

From the paginated object returned by Laravel, the section that interests us most is links, which contains an array with all the necessary links for navigation:

Structure of the paginated prop in Inertia

Frontend with Vue 3 + Inertia: receiving paginated data

In your Vue page, you receive the paginated result as an Object type prop:

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

From there, you can access categories.data to render the records and categories.links to build the navigation.

Iterating the links array correctly

The links array comes prepared to be rendered directly, so iterating over it with v-for is sufficient:

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

But here the first real problem appears: the disabled "Previous" and "Next" links have url: null, and the active page link would lead the user to the same page they are already on. We need to handle both cases.

Avoiding links to the current page

When active === true, rendering a <Link> that navigates to itself makes no sense. Furthermore, it harms accessibility and can generate unnecessary requests. In my case, I solved it by separating <Link> and <span> with a 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>

It works well, but the syntax with the repeated v-if/v-else in each template can be simplified considerably with Vue's dynamic component.

Dynamic rendering with <component>: creating a reusable Pagination.vue component with Tailwind 4

Vue offers a special tag called <component> that allows dynamically rendering a Vue component or a native HTML element based on a condition evaluated in the :is prop:

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="..."> is Vue's mechanism to dynamically render a registered component or a native HTML element according to a condition evaluated at runtime.

Applied to our pagination, we can decide between rendering Inertia's Link component or a native <span> in a single line:

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

The result is identical to the previous one, but the syntax is much cleaner and easier to maintain. From here on, we can add whatever Tailwind 4 classes we want without touching the logic.

To reuse this component anywhere in the project, we encapsulate it in an independent file that receives the paginated list via a 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>

To prevent the active page from behaving like a clickable link, we render a <span> instead. This improves accessibility and avoids unnecessary server requests:

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

And we add styles to make it visually clear that this element is disabled:

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

Once you have this working at a basic level, it makes sense to encapsulate it into a shared component so as not to repeat the same code on every page that needs it.

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>

This version uses <script setup>, which is the recommended approach in Vue 3 with the Composition API. The ?? operator in :href="l.url ?? '#'" ensures that disabled buttons do not throw an empty prop error.

To use it, simply import it and pass the links prop:

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

Clean, consistent, and reusable pagination across any list in the project.

From the category list, its usage would look like this:

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
     },
// ***

And the result in the browser, with the URL synchronized:

http://localhost/category?page=1

 

Custom pagination with Tailwind 4 in Laravel Inertia

Infinite Scroll in Laravel Inertia (Official API)

Classic pagination is ideal for admin panels, but when working with feeds, chats, or galleries, Infinite Scroll offers a considerably smoother user experience. The good thing is that Inertia includes official support for this, without needing to install third-party libraries.

When to use Infinite Scroll?

  • Long lists where displaying the total count adds no value
  • Social media feeds (posts, notifications, activity)
  • Chats (especially in reverse mode)
  • Image and product galleries or grids
  • Related products or recommendations sections

I don't use it for everything, but when it fits, the improvement in user experience is evident.

Backend: Inertia::scroll()

Inertia offers a specific server-side method to automatically configure scroll behavior:

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

The Inertia::scroll() method:

  • Automatically configures data merging between pages, so that new records are appended to the existing array instead of replacing it
  • Normalizes the required metadata so the frontend knows when more pages are available
  • Is compatible with paginate, simplePaginate, and cursorPaginate
  • Is compatible with Laravel's API Resources

In Vue, the <InfiniteScroll /> component is imported directly from @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>

Internally, <InfiniteScroll> uses the browser's Intersection Observer API to detect when the user reaches the end of visible content and, at that moment, requests the next page without replacing the already loaded content.

Buffer, URL sync, and reset

You can control the scroll activation threshold with the :buffer prop. The value indicates in pixels how far before the end of the content the load is triggered:

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

If you prefer that the URL does not change when loading new pages (useful in certain UX cases), use preserve-url:

<InfiniteScroll data="users" preserve-url>

And when the user applies a filter, it is essential to reset the list so that new results do not mix with previous ones:

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

The reset option instructs Inertia to clear the users prop before applying the new data, preventing the feed from accumulating results from previous searches.

Advanced modes of <InfiniteScroll>

Load only the next page (without a previous page button):

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

reverse mode (ideal for chat interfaces, where the most recent messages appear at the bottom):

<InfiniteScroll data="messages" reverse />

You can also disable automatic loading and manually control when to load more content, which is useful if you prefer to show a "Load more" button instead of automatic scroll.

Classic pagination vs Infinite Scroll: when to use each?

Use caseBest option
Admin panelClassic pagination
Social feed (posts, notifications)Infinite Scroll
Chat / messagingInfinite Scroll (reverse)
Strict SEO (Google indexes content)Classic pagination
Modern UX / fluid experienceInfinite Scroll
Tables with filters and searchClassic pagination

Frequently asked questions

  • Does Infinite Scroll affect SEO?
    • In principle no, because Inertia keeps the URL synchronized with the ?page= parameter. However, if SEO is a critical priority and you need Google to index every single page of results, classic pagination remains the most predictable choice.
  • Can I combine classic pagination and Infinite Scroll in the same project?
    • Yes, absolutely. You can use classic pagination in the admin panel and Infinite Scroll on the public section or application feed. They are not mutually exclusive.
  • Which one is easier to maintain?
    • Classic pagination, without a doubt. Infinite Scroll is visually more powerful and elegant, but it involves managing merge states, resets on filtering, and scroll edge cases. Start with classic pagination and migrate when you have a real need.

Conclusion

Pagination in Laravel Inertia is not a problem: it is an opportunity to build a solid, reusable experience tailored to each context. Start with classic pagination by creating your own Pagination.vue component, add whichever Tailwind 4 styles you want, and when the project calls for it, make the leap to Infinite Scroll using Inertia's official API.

It is exactly the path I recommend and the one that scales best in real projects: evolving the solution incrementally, without over-engineering from the start.

The next step is to learn about another component that, unlike the one we just built, comes included in Inertia out of the box: the Progress bar and spinner.

Learn how to create a custom pagination component in Laravel Inertia using Vue 3 and Tailwind 4. Includes a step-by-step guide featuring a reusable `Pagination.vue` component and Infinite Scroll using the official API.


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

I agree to receive announcements of interest about this Blog.