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.
Table of Contents
- 1. Infinite Scroll: Concept and Browser Support
- 2. The Intersection Observer API Explained
- 3. Alpine.js State Design for Infinite Scroll
- 4. Registering and Cleaning Up the Observer in x-init
- 5. Loading More Products From Magento REST
- 6. Showing Loading State, Errors, and End of List
- 7. Reactively Updating the URL Page Number
- 8. SEO Considerations for Infinite Scroll
- 9. Infinite Scroll vs. Load More Button Compared
- 10. Summary
- 11. FAQ
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.