Alpine.js Infinite Scroll: Lazy Loading Without an External Library
AI generated
x-data
Alpine
Alpine.js · Infinite Scroll · Intersection Observer · Hyva Themes
Infinite Scroll With Alpine.js
Load More Products Without an External Library

The Intersection Observer is a native browser API that detects when an element scrolls into the visible viewport, with no scroll event listeners and no performance penalty. Combined with Alpine.js and the Magento REST API, it becomes a complete infinite scroll system in under 60 lines of code.

13 min read Intersection Observer · x-init · fetch · URL state · x-for Alpine.js 3.x · Hyva Themes · Magento 2.4

1. Infinite Scroll: Concept and Browser Support

Infinite scroll is the pattern where new content loads automatically once the user reaches the end of the currently displayed list. It replaces classic pagination with continuous scrolling, familiar from social media feeds but also widely used in e-commerce product listings. The challenge is triggering the load exactly when the user is close to the end of the list, not too early (unnecessary requests) and not too late (a visible gap).

The Intersection Observer has been available in all modern browsers since 2018 and supported in Safari since 2020. It is, de facto, the standard mechanism for scroll-based behavior. The old pattern, window.addEventListener('scroll', handler) combined with getBoundingClientRect(), is slow, runs on the main thread, and blocks rendering under heavy use. The Intersection Observer, on the other hand, runs asynchronously and is fully non-blocking.

For Hyva Themes, infinite scroll is a frequently requested feature on category and search result pages. Hyva ships no built-in infinite scroll system. It must be implemented either via a third-party module or, as shown in this article, as a custom Alpine.js component. The solution integrates cleanly with the existing Hyva product grid and the Magento REST API.

2. The Intersection Observer API Explained

An IntersectionObserver watches whether a target element crosses the boundary of another element (or the viewport). The constructor takes a callback and an options object. The callback fires whenever the intersection status of the observed element changes. entry.isIntersecting is true when the element is visible and false when it is outside the viewport.

The rootMargin option is critical for infinite scroll: with rootMargin: '200px' the callback already fires when the element appears 200 pixels before the bottom edge of the viewport. This means loading starts before the user actually sees the end, so the transition feels seamless. The threshold option controls what percentage of the element must be visible before the callback fires. For infinite scroll, threshold: 0 is enough, meaning as soon as a single pixel becomes visible.


// Alpine.js Infinite Scroll: core state and observer setup
document.addEventListener('alpine:init', () => {
  Alpine.data('infiniteProductList', (config = {}) => ({
    products: config.initial ?? [],
    currentPage: config.startPage ?? 1,
    totalPages: config.totalPages ?? 1,
    loading: false,
    error: null,
    categoryId: config.categoryId,
    pageSize: config.pageSize ?? 12,

    // IntersectionObserver instance, stored for cleanup
    _observer: null,

    get hasMore() {
      return this.currentPage < this.totalPages;
    },

    init() {
      // Only set up observer if there are more pages to load
      if (!this.hasMore) return;

      this._observer = new IntersectionObserver(
        (entries) => {
          const sentinel = entries[0];
          if (sentinel.isIntersecting && this.hasMore && !this.loading) {
            this.loadNextPage();
          }
        },
        {
          rootMargin: '200px',  // trigger 200px before visible
          threshold: 0,
        }
      );

      // Observe the sentinel element, a div at the bottom of the list
      const sentinel = this.$el.querySelector('[data-sentinel]');
      if (sentinel) this._observer.observe(sentinel);
    },

    destroy() {
      // Clean up observer when component is removed from DOM
      if (this._observer) {
        this._observer.disconnect();
        this._observer = null;
      }
    },
  }));
});

3. Alpine.js State Design for Infinite Scroll

The state of the infinite scroll system consists of a few clearly defined values: products as the array of all products loaded so far, currentPage as the current page, totalPages as the total page count (known from the first response), loading as the flag for loading state, and error for error messages. Server-side rendering delivers the first page of products as a PHP-generated JSON array, passed in as the config.initial parameter.

The computed getter hasMore encapsulates the logic for whether more pages are available. Every template part (loading indicator, end-of-list message, load-more hint) refers back to this single getter. When currentPage or totalPages changes, hasMore updates automatically and every dependent template part follows suit. This is reactive programming in its purest form.

4. Registering and Cleaning Up the Observer in x-init

The Intersection Observer is registered in the init() method, which Alpine.js calls automatically when the component mounts. The observed element is a sentinel element: an empty <div data-sentinel></div> at the end of the product list. When this element enters the viewport, the observer callback fires and triggers loading more content. The sentinel element is selected from the DOM with this.$el.querySelector('[data-sentinel]').

Cleaning up the observer is just as important as creating it. Alpine.js calls destroy() when the component is removed from the DOM. There, this._observer.disconnect() is called. Without this cleanup, the observer keeps watching the sentinel element even after the component no longer exists, a classic memory leak. Alpine's destroy() lifecycle method turns cleanup into a natural part of the component lifecycle.


// Load next page from Magento REST API
async loadNextPage() {
  if (this.loading || !this.hasMore) return;

  this.loading = true;
  this.error = null;

  try {
    const nextPage = this.currentPage + 1;
    const url = new URL('/rest/V1/products', window.location.origin);
    url.searchParams.set('searchCriteria[filterGroups][0][filters][0][field]', 'category_id');
    url.searchParams.set('searchCriteria[filterGroups][0][filters][0][value]', this.categoryId);
    url.searchParams.set('searchCriteria[filterGroups][0][filters][0][conditionType]', 'eq');
    url.searchParams.set('searchCriteria[pageSize]', this.pageSize);
    url.searchParams.set('searchCriteria[currentPage]', nextPage);
    url.searchParams.set('searchCriteria[sortOrders][0][field]', 'position');
    url.searchParams.set('searchCriteria[sortOrders][0][direction]', 'ASC');
    url.searchParams.set('fields', 'items[id,sku,name,price,custom_attributes],total_count');

    const response = await fetch(url.toString(), {
      headers: { 'X-Requested-With': 'XMLHttpRequest' }
    });

    if (!response.ok) throw new Error(`HTTP ${response.status}`);

    const data = await response.json();

    // Append new products to existing list
    this.products.push(...data.items);
    this.currentPage = nextPage;
    this.totalPages = Math.ceil(data.total_count / this.pageSize);

    // Update browser URL to reflect current page
    this.updateUrl(nextPage);

  } catch (err) {
    this.error = 'Products could not be loaded. Please try again.';
    console.error('[infiniteScroll] loadNextPage error:', err);
  } finally {
    this.loading = false;
  }
},

5. Loading More Products From Magento REST

The Magento REST API delivers product lists through the /rest/V1/products endpoint using SearchCriteria parameters. The most important parameters for a category list are: filterGroups for filtering to a category ID, pageSize for the number of products per page, and currentPage for the requested page. The fields parameter limits the response size to the required fields; without it, Magento returns every product attribute, which inflates the response by a large factor.

The first page comes from server-side rendering: PHP loads the first 12 products and serializes them as JSON into the template. Alpine.js accepts this array as the config.initial parameter and uses it as the initial products array. Every subsequent page is loaded through the REST API and appended to the existing array with this.products.push(...data.items). Alpine.js propagates the change to every x-for iteration in the template, and the new product cards appear instantly.

6. Showing Loading State, Errors, and End of List

The loading state is controlled by the loading flag. In the template, a spinner or a skeleton loader appears with x-show="loading". The end of the list is signaled with x-show="!hasMore && !loading", and a message like "All 48 products loaded" gives the user clear feedback. Errors are shown with x-show="error" and x-text="error", together with a retry button that calls loadNextPage() again.

In a Hyva context, the skeleton loader is an empty product card structure using Tailwind classes such as animate-pulse bg-slate-200 rounded. It gives the user visual feedback that content is loading without a spinner breaking the flow of attention. The number of skeleton cards should match pageSize so the transition between skeleton and real cards feels seamless.

7. Reactively Updating the URL Page Number

If a user is on page 3 of an infinite scroll list and reloads the page, they should land back on page 3, not page 1. The solution is the History API: history.replaceState(null, '', `?p=${page}`) updates the URL without a page reload. This lets the user bookmark or share the current position.

On initial load, Alpine.js reads the p parameter from the URL and starts on the corresponding page. To do this, init() checks the URL parameter and sets currentPage accordingly. All pages from 1 up to the current page then need to be loaded from the API, either sequentially or in a single request with an increased pageSize value (pageSize times p). This is the classic trade-off: accuracy versus request count.


// URL management: keep browser URL in sync with current scroll position
updateUrl(page) {
  if (!history.replaceState) return;
  const url = new URL(window.location.href);
  if (page > 1) {
    url.searchParams.set('p', page);
  } else {
    url.searchParams.delete('p');
  }
  history.replaceState({ page }, '', url.toString());
},

// Read initial page from URL, call at start of init()
getInitialPage() {
  const params = new URLSearchParams(window.location.search);
  return parseInt(params.get('p') ?? '1', 10);
},

// Re-observe sentinel after DOM update (needed when sentinel was hidden)
async reObserveSentinel() {
  await this.$nextTick();
  if (this._observer && this.hasMore) {
    const sentinel = this.$el.querySelector('[data-sentinel]');
    if (sentinel) {
      this._observer.unobserve(sentinel);
      this._observer.observe(sentinel);
    }
  }
},

8. SEO Considerations for Infinite Scroll

Infinite scroll has a fundamental weakness from an SEO perspective: search engine crawlers do not scroll. They typically only see the first page. That means products on page 2 and beyond are unreachable for Google if they are loaded exclusively via JavaScript. For infinite scroll lists, Google recommends setting <link rel="next"> and <link rel="prev"> tags in the <head> and keeping paginated versions of the pages accessible.

The cleanest solution in a Magento context is a hybrid strategy: the server renders the first page fully as HTML, and standard pagination URLs (/category?p=2, /category?p=3) remain reachable and indexable as standalone pages. Alpine.js then enhances the experience for JavaScript-capable browsers with infinite scroll, but as progressive enhancement, not as the only way to access the data. This strategy combines solid SEO with an excellent user experience.

9. Infinite Scroll vs. Load More Button Compared

Infinite scroll and a load more button solve the same technical problem with different UX consequences. The choice depends on context: product lists where the footer needs to stay reachable suffer under infinite scroll. Discovery-oriented lists benefit from it.

Criterion Infinite Scroll Load More Button Classic Pagination
User Control Low, automatic Good, explicit action Full
SEO Friendliness Poor without a fallback Medium Very good
Discovery UX Very good Good Medium
Footer Reachability Problematic No problem No problem
Alpine.js Implementation Intersection Observer @click + loadNextPage No JS required

In e-commerce projects, a hybrid approach has proven itself: infinite scroll for mobile devices (touch scrolling feels natural) and a load more button for desktop. Alpine.js makes this possible with a simple conditional: on mobile the Intersection Observer gets registered, on desktop a button appears instead that calls loadNextPage(). Same loading logic, different UX triggers.

Mironsoft

Alpine.js, Hyva Themes, and Magento 2 frontend development

Need infinite scroll for your Magento Hyva shop?

We implement infinite scroll and load more systems for Hyva Themes: SEO friendly, performance optimized, and fully integrated with Alpine.js.

Category Lists

Infinite scroll with the Magento REST API, URL state, and an SEO fallback

Search Results

Infinite scroll for Elasticsearch-based search with filter integration

Mobile First

Adaptive UX: infinite scroll on mobile, load more on desktop

10. Summary

Infinite scroll with Alpine.js and the Intersection Observer is a clearly structurable, performant feature that needs no external library. The Intersection Observer fires the load-more call asynchronously and non-blockingly as soon as the sentinel element enters the viewport. Alpine.js holds the entire state, product list, current page, loading state, error, reactively and propagates changes into the template instantly. The History API keeps the browser URL in sync with the current scroll position.

SEO is the most important constraint: infinite scroll must be implemented as progressive enhancement on top of paginated fallback URLs. Google's crawler only sees the first page. The cleanest solution for Magento is server-side rendering of the first page and REST API based loading for subsequent pages, with standard pagination URLs as indexable alternatives.

Alpine.js Infinite Scroll: The Essentials at a Glance

Intersection Observer

Asynchronous, non-blocking. rootMargin: '200px' for early triggering. threshold: 0. Observe a sentinel element at the end of the list. disconnect() in destroy().

State Design

products[], currentPage, totalPages, loading, error. hasMore getter as the single source for all template conditions. Append REST responses with push().

URL and SEO

history.replaceState for URL sync. Pagination URLs (?p=2) as an SEO fallback. First page server rendered. REST only for subsequent pages.

UX States

Skeleton loader (animate-pulse) instead of a spinner. End-of-list message when !hasMore. Retry button on error. Adaptive UX: Intersection Observer on mobile, button on desktop.

11. FAQ: Alpine.js Infinite Scroll

1Why use the Intersection Observer instead of a scroll listener?
The Intersection Observer is asynchronous and non-blocking. Scroll listeners run synchronously on the main thread. No performance overhead, simpler implementation, automatic cleanup.
2How do I prevent the observer from firing multiple times?
Use the loading flag in the observer callback condition: if (isIntersecting && hasMore && !loading). While a request is in flight, further observer callbacks are ignored.
3How do I detect the end of the product list?
totalPages = Math.ceil(total_count / pageSize) from the REST response. hasMore getter: currentPage < totalPages. When false: hide the sentinel, disconnect the observer, show an end-of-list message.
4Is infinite scroll SEO friendly in Magento?
Only as progressive enhancement. Render the first page server side. Keep paginated URLs (?p=2) accessible as standalone, indexable pages. Google does not crawl JS-based lazy loading.
5How do I update the browser URL while scrolling?
history.replaceState(null, '', '?p=' + currentPage) after every loadNextPage. On reload, Alpine reads the parameter and starts on the corresponding page.
6How do I avoid memory leaks with the observer?
Store the observer instance in _observer. In Alpine's destroy(): this._observer.disconnect(). Alpine calls destroy() automatically when the component is removed from the DOM.
7How do I render the first page server side?
PHP serializes the first 12 products as JSON into the template. Alpine takes them as config.initial. currentPage starts at 1. Subsequent pages come via REST and get appended to the products array with push().
8What is a sentinel element?
An empty div at the end of the list: <div data-sentinel></div>. The observer watches it. When it enters the viewport, loadNextPage is triggered. Invisible, no impact on layout.
9How do I implement a load more button instead of automatic scrolling?
No observer, no sentinel. Button with @click="loadNextPage()" and :disabled="loading". loadNextPage method stays identical. Adaptive variant: observer on mobile, button on desktop.
10How do I show a skeleton loader instead of a spinner?
x-show="loading" on a grid with empty card structures: animate-pulse bg-slate-200 rounded. Number of skeletons equals pageSize. No flicker, since it occupies the same space as real cards.