Create a Custom Pagination Component in Laravel Inertia and Tailwind 4

Video thumbnail

When working with Laravel + Inertia + Vue 3, sooner or later you reach the same point: you have a large list and you need pagination. Inertia doesn't come with an "out-of-the-box" pagination component, but it does give you all the necessary pieces to build a clean, reusable, and modern solution.

In this guide, I'll show you how to implement pagination in Laravel Inertia, first in a classic way and then taking the leap to Infinite Scroll, using the official Inertia API. It is exactly the workflow I followed myself when building real lists in administration panels and dashboards.

What does Laravel return when paginating with Inertia?

When you use any of Laravel's pagination methods (paginate, simplePaginate, cursorPaginate) and send them to Inertia, the framework automatically serializes the necessary information for the frontend.

A typical pagination object includes:

  • data: the records for the current page
  • links: the navigation links
  • meta: additional information (current page, total, etc.)

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 or React begins.

In Inertia, we don't have a pagination component to use from the client (Vue or React); therefore, we have to create one; remember that we left off in this guide with the use of flash messages in Laravel Inertia.

Backend: preparing pagination in Laravel

On the backend, there isn't much mystery, but there are best practices:

  • Always paginate on the server
  • Avoid bringing back full collections
  • Maintain consistency in filters and sorting

A more realistic 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 prevents them from being lost when changing pages.

From the paginated list, we should check the links section where all the pagination links are located in an array:

Paginated prop structure

Frontend with Vue 3 + Inertia

Receiving paginated data

In your page:

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

And you can already use categories.data and categories.links.

Iterating links correctly

The links array comes ready to render, so just iterate over it:

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

But here the first real problem appears…

Avoiding links to the current page

When active === true, it makes no sense to render a link that navigates to itself. In my case, I solved it by separating <Link> and <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>

It works, but it can be cleaned up much more.

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

In Vue, we have a tag called "Component" with which we can render components dynamically based on a condition evaluated with 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>

Vue has the <Component> component that allows dynamically rendering HTML or components according to a condition.

With this component in Vue, we can render either a Vue component or HTML directly from its definition:

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

And with this, we have the same result as before, but the syntax is cleaner; as you can see, we can add Tailwind 4 at any time.

Now, to be able to reuse the component globally, we create a new component that receives the paginated list as a parameter via a prop and styled with 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>

To disable the current page and avoid leaving a link that navigates to itself, we can use a conditional and place a SPAN when the current page is used:

<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 add some styling to indicate it is a disabled link, although it is actually a SPAN tag:

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

When you already have this working, the logical step is to reuse it. I usually create a shared component.

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>

Then you use it like this:

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

And that's it. Clean, consistent, and reusable pagination.

From the category list, we use this component:

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 we will get:

http://localhost/category?page=1

 

Custom pagination

Infinite Scroll in Laravel Inertia (Official)

When classic pagination falls short (feeds, chats, grids), Infinite Scroll is the best option.

When to use Infinite Scroll

  • Long lists
  • Social media type feeds
  • Chats
  • Galleries or grids
  • Related products

I don't use it for everything, but when it fits, the user experience improves significantly.

Backend: Inertia::scroll()

Inertia offers a specific method for this:

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

This method:

Automatically configures data merging

  • Normalizes metadata
  • Works with paginate, simplePaginate, cursorPaginate
  • Works with API Resources
  • Frontend: <InfiniteScroll />

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

Internally it uses Intersection Observer and loads pages without replacing the content.

Buffer, URL sync and reset

You can control when the data is loaded:

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

You can also prevent the URL from changing:

<InfiniteScroll data="users" preserve-url>

And when you apply filters, it is important to reset:

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

This prevents new results from mixing with previous ones.

Advanced Modes

Next page only:

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

Reverse mode (ideal for chats):

<InfiniteScroll data="messages" reverse />

You can also activate manual mode and control yourself when to load more content.

Classic Pagination vs Infinite Scroll

  • Case Best option
  • Admin Panel Classic Pagination
  • Social Feed Infinite Scroll
  • Chat Infinite Scroll (reverse)
  • Strict SEO Classic Pagination
  • Modern UX Infinite Scroll

Frequently Asked Questions

  • Does Infinite Scroll affect SEO?
    • No, Inertia keeps the URL synchronized with ?page=.
  • Can I combine pagination and infinite scroll?
    • Yes, in different sections of the same project.
  • Which one is easier to maintain?
    • Classic pagination. Infinite Scroll is more powerful, but also more complex.

Conclusion

Pagination in Laravel Inertia is not a problem: it is an opportunity to build a solid and reusable experience. Start with classic pagination, create your components and, when the project demands it, take the leap to Infinite Scroll using the official API.

It is exactly the path I recommend  and the one that scales best in real projects.

Next step, let's learn about the use of another component that, unlike this one, already exists in Inertia, the Progress bar and spinner.

I agree to receive announcements of interest about this Blog.

Learn how to implement pagination in Laravel Inertia with Vue 3 and Tailwind 4: reusable component, best practices and official infinite scroll step by step.

| 👤 Andrés Cruz

🇪🇸 En español