I added incremental loading to an admin dashboard that displayed transaction records. Admins often scanned several consecutive pages, so scrolling to the pagination controls and waiting for a full reload became repetitive.
Search and filters were still better for finding a specific record. The change was meant for continuous browsing, not as a replacement for every kind of navigation.
Keep Laravel Pagination as the Fallback
I kept server-side pagination as the source of truth and added JavaScript on top. Without JavaScript, the next-page URL still returns a normal page.
public function index(Request $request)
{
$transactions = Transaction::query()
->orderByDesc('created_at')
->orderByDesc('id')
->simplePaginate(20)
->withQueryString();
if ($request->ajax()) {
return view(
'dashboard.transactions.partials.list',
compact('transactions'),
);
}
return view(
'dashboard.transactions.index',
compact('transactions'),
);
}
simplePaginate() is enough because the interface only needs previous and next
URLs. withQueryString() preserves active filters. Ordering by timestamp and
ID keeps the result deterministic when two records share the same timestamp.
It does not remove offset pagination’s behavior when records are inserted or
deleted between requests; a frequently changing list may need cursor
pagination instead.
Use a Real Link and a Status Region
The main Blade view contains the list, a visual loading indicator, and a status message for assistive technology:
<div id="transactions-container" aria-busy="false">
@include('dashboard.transactions.partials.list')
</div>
<div id="loading" class="hidden text-center py-4" aria-hidden="true">
<span class="spinner"></span> Loading more...
</div>
<p
id="load-status"
class="sr-only"
role="status"
aria-atomic="true"
></p>
The partial renders records and a real next-page link:
@foreach($transactions as $transaction)
<article class="transaction-item border-b py-3" tabindex="-1">
<h3 class="font-semibold">{{ $transaction->title }}</h3>
<p class="text-gray-600">{{ $transaction->amount }}</p>
<span class="text-sm text-gray-400">
{{ $transaction->created_at->diffForHumans() }}
</span>
</article>
@endforeach
@if($transactions->hasMorePages())
<a
class="load-trigger block py-4 text-center"
href="{{ $transactions->nextPageUrl() }}"
>
Load more transactions
</a>
@endif
The link remains useful in three cases: JavaScript is unavailable, automatic loading fails, or a user chooses to activate it manually.
Load the Next Fragment
An IntersectionObserver watches the link and requests the next partial when
it approaches the viewport:
const container = document.getElementById('transactions-container');
const loadingIndicator = document.getElementById('loading');
const statusMessage = document.getElementById('load-status');
let isLoading = false;
const observer = new IntersectionObserver(
(entries) => {
const visibleTrigger = entries.find((entry) => entry.isIntersecting);
if (visibleTrigger) loadMore(visibleTrigger.target);
},
{ rootMargin: '200px' },
);
async function loadMore(trigger, { moveFocus = false } = {}) {
if (isLoading || !trigger?.href) return;
isLoading = true;
observer.unobserve(trigger);
container.setAttribute('aria-busy', 'true');
loadingIndicator.classList.remove('hidden');
loadingIndicator.setAttribute('aria-hidden', 'false');
statusMessage.textContent = 'Loading more transactions.';
const previousItemCount = container.querySelectorAll(
'.transaction-item',
).length;
try {
const response = await fetch(trigger.href, {
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const html = await response.text();
trigger.remove();
container.insertAdjacentHTML('beforeend', html);
const items = container.querySelectorAll('.transaction-item');
const addedItemCount = items.length - previousItemCount;
statusMessage.textContent = `${addedItemCount} more transactions loaded.`;
observeNextTrigger();
if (moveFocus && addedItemCount > 0) {
items[previousItemCount].focus();
}
} catch (error) {
console.error('Error loading more:', error);
trigger.textContent = 'Try loading again';
statusMessage.textContent =
'Could not load more transactions. Use the retry link to try again.';
} finally {
isLoading = false;
container.setAttribute('aria-busy', 'false');
loadingIndicator.classList.add('hidden');
loadingIndicator.setAttribute('aria-hidden', 'true');
}
}
function observeNextTrigger() {
const triggers = container.querySelectorAll('.load-trigger');
const nextTrigger = triggers[triggers.length - 1];
if (nextTrigger) observer.observe(nextTrigger);
}
container.addEventListener('click', (event) => {
const trigger = event.target.closest('.load-trigger');
if (!trigger) return;
event.preventDefault();
loadMore(trigger, { moveFocus: true });
});
observeNextTrigger();
The loading flag and unobserve() prevent duplicate requests. Checking
response.ok prevents an HTTP error page from being appended as transaction
markup. Because insertAdjacentHTML() parses the response as HTML, this
example also assumes a trusted same-origin partial whose dynamic values remain
escaped by Blade. It should not append arbitrary third-party HTML.
Handle Failure and Focus Deliberately
When a request fails, I keep the existing link and change its label to “Try loading again”. I do not observe it again immediately because a visible failed link could create an automatic retry loop.
Manual activation moves focus to the first new record. Automatic loading does not move focus, because a scroll event should not unexpectedly move a keyboard or screen-reader user’s position. The status region announces loading, success, and failure in both cases.
An empty result renders a message instead of the trigger. The growing list also needs a limit: every appended page increases the DOM size, and the current URL does not represent the exact scroll position. Those trade-offs still require keyboard and screen-reader testing in the application rather than being solved by the code sample alone.
When This Pattern Does Not Fit
I would keep numbered pagination as the primary interface when users need to:
- jump directly between distant pages;
- bookmark or share an exact page;
- work with page ranges for exports;
- return reliably to a previous position;
- browse a list large enough that the growing DOM becomes expensive.
An explicit “Load more” button or cursor pagination can also be a better fit, depending on how often the underlying records change and how much navigation control users need.
What Changed
The implementation reduced repeated page navigation while retaining Laravel’s page boundaries and filter URLs. One admin told me:
I didn’t realize how annoying pagination was until I didn’t have to deal with it anymore.
The useful part was not removing pagination. It was changing how the next page was requested while keeping the original link, error recovery, and server-side behavior available underneath it.