You want infinite scroll in a Livewire list. The usual route is to hand-roll an IntersectionObserver in Alpine, call $wire.loadMore(), and then spend an afternoon fighting the bug where it fires forever. Livewire 4 ships wire:intersect, which does the observer wiring for you. The directive is well documented, but the two things that actually break in production, the fire-forever loop and offset pagination drifting under live inserts, never show up in the same walkthrough. Here is the whole pattern.
If you are still on an older version, read the Livewire 3 to 4 migration guide first, because wire:intersect and islands are both new in v4.
Switch to cursor pagination#
Start at the database, not the template. Offset pagination (paginate(), skip()/take()) counts rows from the top every time, so it gets slower the deeper you scroll, and any row inserted above the current window shifts every subsequent page by one, which means infinite scroll either duplicates or skips items. Cursor pagination keys off the last row you saw, so it stays O(1) at any depth and is immune to inserts above the window. That trade-off is exactly what infinite scroll needs.
use App\Models\Post;
$page = Post::query()
->latest()
->cursorPaginate(perPage: 10, cursor: $this->cursor);
$page->items(); // the 10 models for this slice
$page->nextCursor(); // a Cursor instance, or null on the last page
$page->hasMorePages(); // bool
The only constraint: cursor pagination requires a stable, ordered column. latest() (ordering by created_at then the primary key) is fine. Avoid ordering by a column that can change between requests.
Render the current page in an island#
Keep the items you have already loaded in a public array and render them in a normal @foreach. Storing plain arrays instead of full Eloquent models keeps the component payload small, hydrate only the fields you actually display. The component below loads the first page on mount() and exposes a loadMore() action we will wire up next.
<?php // resources/views/components/post-feed.blade.php
use App\Models\Post;
use Livewire\Volt\Component;
new class extends Component {
/** @var array<int, array{id: int, title: string, excerpt: string}> */
public array $items = [];
public ?string $cursor = null;
public bool $hasMore = true;
public function mount(): void
{
$this->loadMore();
}
public function loadMore(): void
{
$page = Post::query()
->latest()
->cursorPaginate(perPage: 10, cursor: $this->cursor);
// Merge only the fields the view renders, not whole models.
$this->items = [
...$this->items,
...collect($page->items())->map->only(['id', 'title', 'excerpt'])->all(),
];
$this->cursor = $page->nextCursor()?->encode();
$this->hasMore = $page->hasMorePages();
}
};
?>
<div>
@foreach ($items as $post)
<article wire:key="post-{{ $post['id'] }}" class="border-b border-gray-200 py-4">
<h3 class="font-semibold">{{ $post['title'] }}</h3>
<p class="text-gray-600">{{ $post['excerpt'] }}</p>
</article>
@endforeach
</div>
If your list query is genuinely expensive, push it behind a Livewire 4 #[Computed(persist: true)] cache so repeated loadMore calls do not re-run the heavy parts.
Add a sentinel with wire:intersect#
A sentinel is an empty element at the bottom of the list. When it scrolls into view, wire:intersect calls your action. The .margin.200px modifier triggers it 200px before the element actually reaches the viewport, so the next page is loading before the user hits the bottom and the scroll feels seamless. The wire:loading sibling shows a spinner only while the request is in flight.
@if ($hasMore)
<div
wire:key="sentinel-{{ count($items) }}"
wire:intersect.once.margin.200px="loadMore"
>
<div wire:loading wire:target="loadMore" class="py-4 text-center text-gray-400">
Loading more posts…
</div>
</div>
@endif
Place this block directly after the @foreach, inside the same root <div>. The @if ($hasMore) guard removes the sentinel entirely on the last page, so there is nothing left to trigger.
Advance the cursor in loadMore#
The loadMore() method written above already does the three things that matter: it appends the new slice, stores the next cursor with nextCursor()?->encode(), and flips $hasMore off once hasMorePages() returns false. Encoding the cursor to a string is important, a Cursor object is not a primitive, so Livewire cannot keep it as a public property without it. Decoding happens automatically when you pass the string back into cursorPaginate(cursor: ...).
$this->cursor = $page->nextCursor()?->encode(); // null on the final page
$this->hasMore = $page->hasMorePages();
The wire:key="sentinel-{{ count($items) }}" from the previous step is what makes the loop terminate cleanly. Because the key changes every time $items grows, Livewire's morph engine replaces the sentinel with a fresh element each page, which re-arms wire:intersect.once for exactly one more fire. That is the whole fix for the fire-forever bug, covered in detail two sections down.
Append chunks instead of replacing the list#
The array-merge approach above re-sends every loaded item on each request, so by page ten you are shipping 100 items down the wire just to add 10. Livewire 4's islands fix that with append mode: wrap the list in @island(name: 'feed') and add wire:island.append to the trigger, and the server renders and sends only the new slice, appending it to the DOM. You drop the $items array entirely and let a computed property return just the current cursor's page.
<?php
use App\Models\Post;
use Livewire\Attributes\Computed;
use Livewire\Volt\Component;
new class extends Component {
public ?string $cursor = null;
#[Computed]
public function page()
{
return Post::query()->latest()->cursorPaginate(perPage: 10, cursor: $this->cursor);
}
public function loadMore(): void
{
$this->cursor = $this->page->nextCursor()?->encode();
unset($this->page); // bust the memoised computed so it re-runs for the new cursor
}
};
?>
<div>
@island(name: 'feed')
@foreach ($this->page as $post)
<article wire:key="post-{{ $post->id }}" class="border-b py-4">
<h3 class="font-semibold">{{ $post->title }}</h3>
<p class="text-gray-600">{{ $post->excerpt }}</p>
</article>
@endforeach
@if ($this->page->hasMorePages())
<div
wire:key="sentinel-{{ $this->cursor ?? 'first' }}"
wire:intersect.once.margin.200px="loadMore"
wire:island.append="feed"
>
<div wire:loading wire:target="loadMore" class="py-4 text-center text-gray-400">
Loading more posts…
</div>
</div>
@endif
@endisland
</div>
The unset($this->page) line is the part people miss. Computed properties memoise per request, so without busting the cache, the island would re-render the old page after you advance the cursor. I prefer this island version for long feeds because the payload stays flat, but the array-merge version is easier to test and perfectly fine for lists that top out at a few hundred rows.
Add a keyboard-accessible Load more fallback#
Scroll-triggered loading is invisible to keyboard-only and screen-reader users, who never generate the scroll event that fires the observer. Always pair the sentinel with a real button so the content is reachable without a mouse. The button calls the same loadMore() action, and on the island version it carries wire:island.append too.
@if ($hasMore)
<button
type="button"
wire:click="loadMore"
wire:loading.attr="disabled"
wire:target="loadMore"
class="mt-4 w-full rounded-lg border border-gray-300 py-2 hover:bg-gray-50"
>
Load more posts
</button>
@endif
If you want the matching skeleton-row treatment while a page loads, the Livewire 4 @placeholder skeleton loader pattern drops straight into the island and matches your row layout so there is no layout shift.
Gotchas and edge cases#
The fire-forever bug comes from a plain wire:intersect="loadMore" with no .once: the observer fires on every intersection, so as the sentinel hovers near the fold it triggers loadMore on every scroll tick. Adding .once fixes that but introduces the opposite problem, with a static wire:key the element is morphed in place and never re-arms, so you only ever get one extra page. The cure is both together: .once plus a wire:key that changes each load, so each page gets a brand-new sentinel that fires exactly once.
A few more things that bite:
Islands cannot live inside @foreach or @if. Livewire islands have no access to loop or conditional variables, so the @foreach must go inside the island, never the other way around. Dead sentinels accumulate in the island version, each appended chunk carries its own sentinel and the spent ones stay in the DOM as empty divs, which is harmless but worth knowing. And cursor pagination needs a deterministic sort, if two rows share a created_at value and you have no tiebreaker, the cursor can wobble; ordering by latest() includes the primary key as a tiebreaker, so stick with it.
Wrapping up#
Reach for wire:intersect.once.margin.200px on a re-keyed sentinel, back it with cursor pagination, and add a Load more button for accessibility. That is a complete, production-ready infinite scroll with zero hand-written JavaScript. Start with the array-merge version, and move to @island append mode when the payload size starts to bother you.
If infinite scroll is not the right call for your data, a paged table is often clearer, see the Flux UI table with server-driven pagination and sorting for that route.
FAQ#
What does wire:intersect do in Livewire 4?
wire:intersect runs a Livewire action when an element enters or leaves the viewport. It wraps the browser's IntersectionObserver API, so you get scroll-triggered behaviour like infinite scroll, lazy loading, and view tracking without writing any JavaScript. You can target enter or leave specifically with wire:intersect:enter and wire:intersect:leave.
How do I build infinite scroll with Livewire?
Render the items you have loaded in a @foreach, then place an empty sentinel element at the bottom with wire:intersect.once="loadMore". The loadMore() action fetches the next page with cursorPaginate(), appends the new rows, and stores the next cursor. Give the sentinel a wire:key that changes each page so the directive re-arms, and guard it with @if ($hasMore) so it disappears on the last page.
What is the .once modifier on wire:intersect?
.once tells the directive to fire its action only on the first intersection for that element instance, instead of every time the element scrolls back into view. It is essential for infinite scroll and analytics, where re-triggering on every scroll would fire the action far too often. Note that it is scoped per element, so a re-keyed sentinel gets a fresh .once each time it is recreated.
How do I stop wire:intersect firing multiple times?
Add the .once modifier so the observer fires a single time per element. For infinite scroll, also change the sentinel's wire:key on every load (for example wire:key="sentinel-{{ count($items) }}") so Livewire replaces the element each page rather than reusing the spent one. Without the changing key, .once would stop loading after the first extra page; without .once, it would fire on every scroll tick.
Should I use offset or cursor pagination with wire:intersect?
Use cursor pagination. Offset pagination (paginate(), skip()/take()) recounts rows from the top on every request, so it slows down as you scroll deeper, and any row inserted above the current window shifts every later page, causing duplicates or skipped items in an infinite feed. Cursor pagination keys off the last row seen, staying fast at any depth and immune to inserts above the window.
Can I lazy-load Livewire components with wire:intersect?
You can, but Livewire 4 has a more direct tool for it: @island(lazy: true) defers an island's render until it scrolls into view using its own intersection observer, and whole components support lazy="on-viewport". Use wire:intersect when you need to call a specific action on scroll, like loadMore, and use lazy islands when you simply want a region to defer its initial render until visible.