Reader Stacks

Building Infinite Scroll Pagination in Laravel

The scroll listener itself is the easy part — checking whether more pages actually exist, and preventing duplicate simultaneous requests, are what separates a working implementation from a buggy one.

Infinite scroll — automatically loading more results as the user scrolls near the bottom of the page — builds on the same paginated query as standard pagination, with the front-end logic doing the real work of detecting scroll position and preventing duplicate requests.

The controller

public function index(Request $request)
{
    $products = Product::query()
        ->when($request->filled('search'), fn ($q) => $q->where('name', 'like', '%'.$request->search.'%'))
        ->paginate(10);

    if ($request->ajax()) {
        return response()->json([
            'html' => view('products.partials.list', compact('products'))->render(),
            'hasMore' => $products->hasMorePages(),
        ]);
    }

    return view('products.index', compact('products'));
}

hasMorePages() is what tells the front end whether to keep listening for further scroll events or stop — without this flag, the scroll listener would keep firing requests for pages that no longer return any new results.

Detecting when the user has scrolled near the bottom

let currentPage = 1;
let isLoading = false;
let hasMore = true;

$(window).scroll(function () {
    if (isLoading || !hasMore) return;

    const nearBottom = $(window).scrollTop() + $(window).height() >= $(document).height() - 300;

    if (nearBottom) {
        loadMoreProducts();
    }
});

function loadMoreProducts() {
    isLoading = true;
    currentPage++;

    $.get('/products', { page: currentPage }, function (response) {
        $('#product-list').append(response.html);
        hasMore = response.hasMore;
        isLoading = false;
    });
}

The 300px buffer before the actual bottom of the page gives the next batch time to load before the user physically reaches the end of the currently visible content, avoiding a moment of visibly hitting a dead-end.

Why the isLoading flag is essential, not optional

Without isLoading, a user scrolling quickly can trigger the scroll event handler many times before the first request even completes, firing multiple simultaneous requests for the same next page — this flag is what prevents both duplicate content from being appended and unnecessary server load from redundant concurrent requests.

Showing a loading indicator during the fetch

function loadMoreProducts() {
    isLoading = true;
    currentPage++;
    $('#loading-spinner').show();

    $.get('/products', { page: currentPage }, function (response) {
        $('#product-list').append(response.html);
        hasMore = response.hasMore;
        isLoading = false;
        $('#loading-spinner').hide();

        if (!hasMore) {
            $('#end-of-results').show();
        }
    });
}

Using IntersectionObserver as a more modern alternative to scroll events

const sentinel = document.getElementById('scroll-sentinel');

const observer = new IntersectionObserver((entries) => {
    if (entries[0].isIntersecting && !isLoading && hasMore) {
        loadMoreProducts();
    }
});

observer.observe(sentinel);

IntersectionObserver, watching a small invisible "sentinel" element placed near the bottom of the content, is a more efficient modern alternative to a scroll event listener, which fires very frequently and can introduce janky scrolling performance if the handler itself does any non-trivial work on every single fire.

Accessibility consideration: infinite scroll's real downside

Infinite scroll can make it difficult for keyboard and screen-reader users to reach page footer content, and it removes the ability to bookmark or share a link to a specific "page" of results — for content where either of these genuinely matters, standard click-through pagination (or a hybrid with a "load more" button, rather than fully automatic scrolling) is a reasonable, more accessible alternative to weigh against pure infinite scroll.