I Tried Three Different Infinite Scroll Plugins Before Realizing Livewire Could Handle It Alone
I was building a product listing page for a small furniture store’s website, around 600 products, and the client wanted that “Instagram-style” scrolling where products just keep loading as you go down, no pagination buttons, no page numbers.

My first move was googling “infinite scroll jquery plugin” and grabbing whatever had the most stars on GitHub. Got it working in about twenty minutes. Felt great about myself. Then I tried adding a Livewire component on the same page for filtering products by category, and everything broke. The plugin didn’t know Livewire had replaced part of the DOM, so scroll detection just stopped firing after the first filter change.
I tried two more plugins after that. Same story, different flavor of broken. That’s when I finally sat down and actually read through Livewire’s own event and lifecycle documentation properly instead of skimming it, and realized I didn’t need any plugin at all. Livewire already has everything required to build infinite scroll natively.
Here’s exactly how I built it, the clean way, without a single external JS library.
Why Plugins Kept Breaking On Me
Quick explanation for anyone who hasn’t run into this yet: most JavaScript infinite scroll plugins attach a scroll listener to the window or a specific container when the page first loads. The problem is, Livewire re-renders parts of the DOM whenever a component updates. If your plugin’s listener was attached to an element that Livewire just swapped out, the listener is gone. No error message, no warning, it just silently stops working.
This is a really common gotcha for people combining Livewire with older jQuery-based plugins, and it wasted way more of my time than it should have.
The Approach That Actually Works
Instead of relying on JavaScript to detect scrolling and then calling a Laravel endpoint separately, I let Livewire handle everything, the data fetching, the state tracking, and the DOM updates, using just a small bit of vanilla JavaScript to detect when the user hits the bottom of the page. That tiny script is the only JS involved, and it works fine alongside Livewire because Livewire itself controls the re-rendering.
Here’s the full breakdown.
Step 1: Setting Up the Livewire Component
php artisan make:livewire ProductList
Inside ProductList.php, I track how many products are currently loaded and increase that number as the user scrolls:
class ProductList extends Component
{
public $perPage = 12;
public function loadMore()
{
$this->perPage += 12;
}
public function render()
{
return view('livewire.product-list', [
'products' => Product::latest()->paginate($this->perPage),
]);
}
}
I know using paginate() here looks a bit unusual since we’re not showing pagination links, but it’s actually the cleanest way to know if there are more products left to load, since the paginator object tells you that automatically.
Step 2: The Blade View
<div>
<div class="grid grid-cols-3 gap-4">
@foreach ($products as $product)
<div class="border rounded p-4">
<h3>{{ $product->name }}</h3>
<p>Rs. {{ number_format($product->price) }}</p>
</div>
@endforeach
</div>
@if ($products->hasMorePages())
<div wire:init="loadMore" x-intersect="$wire.loadMore()" class="text-center py-4">
Loading more products...
</div>
@endif
</div>
That x-intersect directive is doing the heavy lifting here, and it comes from Alpine.js, which ships with Livewire by default in Livewire 3. You don’t need to install anything extra for it. It fires the loadMore method automatically when that specific div scrolls into view, which is exactly the moment we want to load more products.
Step 3: Testing It (And Where I Almost Messed Up)
The first time I tested this, it looked like it worked, until I scrolled fast. Really fast. And noticed duplicate products showing up randomly.
Turns out, x-intersect can fire more than once if the browser is slow to remove the “Loading more products” div from view after new products load in. I fixed this by adding a loading state so the method can’t fire twice in a row:
public $isLoading = false;
public function loadMore()
{
if ($this->isLoading) {
return;
}
$this->isLoading = true;
$this->perPage += 12;
$this->isLoading = false;
}
This isn’t a perfect solution on its own since Livewire requests aren’t instantly synchronous from the browser’s perspective, so I also added wire:loading.attr="disabled" styling on the loading indicator itself, mostly to visually prevent confusion, and combined it with debouncing the intersect trigger slightly.
<div wire:init="loadMore" x-intersect.throttle.500ms="$wire.loadMore()">
Loading more products...
</div>
That .throttle.500ms modifier fixed the duplicate loading issue completely. Small addition, big difference.
Step 4: Adding a Proper Loading Spinner
Right now the “Loading more products” text just sits there permanently at the bottom, which looks a bit off once all products are loaded. Livewire has a built-in wire:loading directive for this exact situation:
<div wire:loading wire:target="loadMore" class="text-center py-4">
<span class="animate-pulse">Fetching more items...</span>
</div>
This only shows while the loadMore method is actually running, so it disappears the rest of the time instead of sitting there as dead space.
Step 5: Handling the “No More Products” State
Something I initially forgot entirely, what happens once every single product has loaded? Right now, nothing shows, which just looks like the page abruptly stopped working.
@if (!$products->hasMorePages())
<p class="text-center text-gray-500 py-4">You've seen everything we've got!</p>
@endif
Small detail, but it makes the whole experience feel finished instead of broken.
Real Performance Consideration I Learned The Hard Way
Once the furniture store’s catalog grew past a few hundred products, I noticed the page getting sluggish the further down someone scrolled. That’s because paginate() was recalculating the entire offset each time perPage increased, meaning by the time someone scrolled to product 300, Laravel was quietly re-querying and re-counting a much bigger dataset every single time.
For genuinely large datasets, cursor-based pagination is a better approach:
Product::latest()->cursorPaginate($this->perPage);
Cursor pagination doesn’t need to know the total count or calculate offsets, it just remembers the last item’s position and grabs the next batch from there. For anything under a thousand or so records, regular paginate() is fine. Past that, cursor pagination noticeably improved load times on my end.
Common Mistakes I’d Warn You About
Forgetting wire:key inside the loop. If you’re using @foreach, always add wire:key="product-{{ $product->id }}". Without it, Livewire sometimes gets confused about which DOM elements changed after new items load, and you’ll see weird flickering or elements swapping positions unexpectedly.
@foreach ($products as $product)
<div wire:key="product-{{ $product->id }}">
...
</div>
@endforeach
Not testing on an actual mobile device. Everything looked fine on my laptop in Chrome DevTools’ mobile emulator, but when I tested on my actual phone (a regular Android phone with a mid-range processor), the intersect trigger fired a split second later than expected because of how mobile scrolling momentum works. The throttle modifier I mentioned earlier fixed this too.
Loading too many items per batch. I initially set perPage to jump by 50 each time, thinking fewer requests meant better performance. It actually made things worse because each batch took noticeably longer to render, causing a visible stutter. Smaller batches, like 10 to 15 items, loaded quicker and felt smoother even though there were more requests overall.
Forgetting to reset perPage when filters change. If your infinite scroll page also has category filters or search, make sure perPage resets back to its original value whenever those filters change. I forgot this once and ended up with a bug where switching categories showed way more products than intended, because the old perPage value carried over from before the filter changed.
public function updatedCategory()
{
$this->perPage = 12;
}
Where This Pattern Actually Comes In Handy
Beyond that furniture store project, I’ve reused this same infinite scroll setup for:
- A blog’s article listing page, so readers keep scrolling through posts without clicking “next page”
- A small job board where listings loaded as recruiters scrolled instead of paginating
- An admin panel’s activity log, where support staff needed to scroll through recent user actions without constant page reloads
None of these needed a JavaScript framework or a paid plugin. Livewire combined with Alpine’s x-intersect handled every single one of them.
Final Thoughts
Looking back, I wasted way more time chasing broken plugins than it actually took to build this properly using tools already sitting inside Livewire. The lesson that stuck with me is pretty simple: before reaching for an external JavaScript library to solve something, it’s worth checking if the framework you’re already using can handle it natively. Livewire 3 shipping with Alpine.js built in means a lot of these “we need a plugin for this” situations don’t actually need one anymore.
If you’re building something similar, start with the basic x-intersect and loadMore setup first, get that working reliably, then layer on the throttling, loading states, and cursor pagination only once your actual dataset size demands it. Don’t over-engineer it on day one for a problem you might not even have yet.



