x-data
Alpine
Alpine.js · Infinite Scroll · Intersection Observer · Hyvä Themes
Infinite Scroll mit Alpine.js
Produkte nachladen ohne externe Bibliothek

Der Intersection Observer ist eine native Browser-API, die erkennt, wenn ein Element in den sichtbaren Bereich scrollt – ohne Scroll-Event-Listener, ohne Performance-Einbußen. In Kombination mit Alpine.js und der Magento-REST-API entsteht ein vollständiges Infinite-Scroll-System in weniger als 60 Zeilen.

13 Min. Lesezeit Intersection Observer · x-init · fetch · URL-State · x-for Alpine.js 3.x · Hyvä Themes · Magento 2.4

1. Infinite Scroll: Konzept und Browser-Support

Infinite Scroll ist das Muster, bei dem neue Inhalte automatisch nachgeladen werden, sobald der Nutzer das Ende der aktuell angezeigten Liste erreicht. Es ersetzt die klassische Paginierung durch ein kontinuierliches Scrollen – bekannt aus Social-Media-Feeds, aber auch in E-Commerce-Produktlisten weit verbreitet. Die Herausforderung liegt darin, das Nachladen genau dann auszulösen, wenn der Nutzer kurz vor dem Listenende ist – nicht zu früh (unnötige Requests), nicht zu spät (sichtbare Leerstelle).

Der Intersection Observer ist seit 2018 in allen modernen Browsern verfügbar und seit 2020 in Safari unterstützt. Er ist damit de facto der Standard-Mechanismus für Scroll-basiertes Verhalten. Das alte Muster – window.addEventListener('scroll', handler) mit getBoundingClientRect() – ist langsam, läuft auf dem Main Thread und blockiert bei intensiver Nutzung das Rendering. Der Intersection Observer dagegen läuft asynchron und ist vollständig nicht-blockierend.

Für Hyvä Themes ist Infinite Scroll ein häufig gefragtes Feature in Kategorielisten und Suchergebnisseiten. Hyvä bietet kein eingebautes Infinite-Scroll-System. Es muss entweder über ein Dritt-Modul oder – was in diesem Artikel gezeigt wird – als eigene Alpine.js-Komponente implementiert werden. Die Lösung integriert sich sauber mit dem bestehenden Hyvä-Produktraster und der Magento-REST-API.

2. Intersection Observer API erklärt

Ein IntersectionObserver beobachtet, ob ein Zielelement die Grenze eines anderen Elements (oder des Viewports) überschreitet. Der Konstruktor nimmt einen Callback und ein Options-Objekt. Der Callback wird aufgerufen, wenn sich der Intersection-Status des beobachteten Elements ändert. entry.isIntersecting ist true, wenn das Element sichtbar ist, false wenn es außerhalb des Viewports ist.

Die rootMargin-Option ist entscheidend für Infinite Scroll: Mit rootMargin: '200px' wird der Callback bereits ausgelöst, wenn das Element 200 Pixel vor dem unteren Viewport-Rand erscheint. So beginnt das Nachladen, bevor der Nutzer das tatsächliche Ende sieht – der Übergang wirkt nahtlos. Die threshold-Option steuert, wie viel Prozent des Elements sichtbar sein müssen, bevor der Callback feuert. Für Infinite Scroll reicht threshold: 0 – also sobald irgendein Pixel sichtbar wird.


// 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 für Infinite Scroll

Der State des Infinite-Scroll-Systems besteht aus wenigen, klar definierten Werten: products als Array aller bisher geladenen Produkte, currentPage als aktuelle Seite, totalPages als Gesamtseitenzahl (aus dem ersten Response bekannt), loading als Flag für den Ladezustand und error für Fehlermeldungen. Das Server-Rendering liefert die erste Seite der Produkte als PHP-generiertes JSON-Array, das als config.initial-Parameter übergeben wird.

Der berechnete Getter hasMore kapselt die Logik, ob weitere Seiten verfügbar sind. Alle Template-Teile (Ladeindikator, Listenende-Meldung, weiterer-laden-Hint) beziehen sich auf diesen einzelnen Getter. Wenn sich currentPage oder totalPages ändert, aktualisiert sich hasMore automatisch und alle davon abhängigen Template-Teile folgen. Das ist reaktive Programmierung in ihrer reinsten Form.

4. Observer in x-init registrieren und aufräumen

Der Intersection Observer wird in der init()-Methode registriert, die Alpine.js beim Mounten der Komponente automatisch aufruft. Das zu beobachtende Element ist ein Sentinel-Element – ein leeres <div data-sentinel></div> am Ende der Produktliste. Wenn dieses Element in den Viewport kommt, feuert der Observer-Callback und löst das Nachladen aus. Das Sentinel-Element wird aus dem DOM durch this.$el.querySelector('[data-sentinel]') selektiert.

Das Aufräumen des Observers ist ebenso wichtig wie das Erstellen. Alpine.js ruft destroy() auf, wenn die Komponente aus dem DOM entfernt wird. Dort wird this._observer.disconnect() aufgerufen. Ohne dieses Cleanup beobachtet der Observer weiterhin das Sentinel-Element, auch wenn die Komponente nicht mehr existiert – ein klassischer Memory Leak. Alpine's Lifecycle-Methode destroy() macht das Cleanup zu einem natürlichen Teil des Komponenten-Lebenszyklus.


// 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 = 'Produkte konnten nicht geladen werden. Bitte versuche es erneut.';
    console.error('[infiniteScroll] loadNextPage error:', err);
  } finally {
    this.loading = false;
  }
},

5. Produkte von Magento REST nachladen

Die Magento REST API liefert Produktlisten über den Endpunkt /rest/V1/products mit SearchCriteria-Parametern. Die wichtigsten Parameter für eine Kategorieliste: filterGroups zum Filtern auf eine Kategorie-ID, pageSize für die Anzahl der Produkte pro Seite und currentPage für die gewünschte Seite. Der fields-Parameter begrenzt die Antwortgröße auf benötigte Felder – ohne ihn liefert Magento alle Produktattribute zurück, was die Antwort um ein Vielfaches aufbläht.

Die erste Seite kommt vom Server-Rendering – PHP lädt die ersten 12 Produkte und serialisiert sie als JSON ins Template. Alpine.js nimmt dieses Array als config.initial-Parameter entgegen und stellt es als initiales products-Array bereit. Jede weitere Seite wird über die REST-API nachgeladen und per this.products.push(...data.items) an das bestehende Array angehängt. Alpine.js propagiert die Änderung an alle x-for-Iterationen im Template, die neuen Produkt-Karten erscheinen sofort.

6. Ladezustand, Fehler und Listenende anzeigen

Der Ladezustand wird durch das loading-Flag gesteuert. Im Template erscheint ein Spinner oder ein Skeleton-Loader mit x-show="loading". Das Listenende wird durch x-show="!hasMore && !loading" signalisiert – eine Meldung wie "Alle 48 Produkte geladen" gibt dem Nutzer klares Feedback. Fehler werden mit x-show="error" und x-text="error" angezeigt, zusammen mit einem Retry-Button, der loadNextPage() erneut aufruft.

Der Skeleton-Loader ist im Hyvä-Kontext eine leere Produktkarten-Struktur mit Tailwind-Klassen wie animate-pulse bg-slate-200 rounded. Er gibt dem Nutzer visuelles Feedback, dass Inhalte geladen werden, ohne dass ein Spinner die Aufmerksamkeit bricht. Die Anzahl der Skeleton-Karten sollte mit pageSize übereinstimmen, damit der Übergang zwischen Skeleton und echten Karten nahtlos wirkt.

7. URL-Seitenzahl reaktiv aktualisieren

Wenn ein Nutzer auf Seite 3 einer Infinite-Scroll-Liste ist und die Seite neu lädt, sollte er wieder bei Seite 3 starten – nicht bei Seite 1. Die Lösung ist die History API: history.replaceState(null, '', `?p=${page}`) aktualisiert die URL ohne Seitenneuladen. So kann der Nutzer die aktuelle Position bookmarken oder teilen.

Beim initialen Laden liest Alpine.js den p-Parameter aus der URL und startet mit der entsprechenden Seite. Dazu prüft init() den URL-Parameter und setzt currentPage entsprechend. Alle Seiten von 1 bis zur aktuellen Seite müssen dann von der API nachgeladen werden – entweder sequenziell oder in einem einzigen Request mit erhöhtem pageSize-Wert (pageSize × p). Das ist die klassische Kompromiss-Frage: Genauigkeit vs. Request-Anzahl.


// 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-Überlegungen bei Infinite Scroll

Infinite Scroll hat aus SEO-Perspektive eine grundlegende Schwäche: Suchmaschinen-Crawler scrollen nicht. Sie sehen in der Regel nur die erste Seite. Das bedeutet: Produkte auf Seite 2 und folgende sind für Google nicht erreichbar, wenn sie ausschließlich über JavaScript nachgeladen werden. Google empfiehlt für Infinite-Scroll-Listen, <link rel="next">- und <link rel="prev">-Tags im <head> zu setzen und paginierte Varianten der Seiten zugänglich zu machen.

Die sauberste Lösung im Magento-Kontext ist eine hybride Strategie: Der Server rendert die erste Seite vollständig mit HTML, die Standard-Paginierungs-URLs (/category?p=2, /category?p=3) bleiben als eigenständige Seiten erreichbar und indexierbar. Alpine.js erweitert das Erlebnis für JavaScript-fähige Browser mit Infinite Scroll – aber als Progressive Enhancement, nicht als einziger Datenzugang. Diese Strategie kombiniert gute SEO mit exzellenter UX.

9. Infinite Scroll vs. Load-More-Button im Vergleich

Infinite Scroll und Load-More-Button lösen dasselbe technische Problem mit unterschiedlichen UX-Konsequenzen. Die Wahl hängt vom Kontext ab: Produktlisten, bei denen der Footer erreichbar sein muss, leiden unter Infinite Scroll. Entdeckungs-orientierte Listen profitieren davon.

Kriterium Infinite Scroll Load-More-Button Klassische Paginierung
Nutzerkontrolle Gering – automatisch Gut – explizite Aktion Vollständig
SEO-Freundlichkeit Schlecht ohne Fallback Mittel Sehr gut
Entdeckungs-UX Sehr gut Gut Mittel
Footer-Erreichbarkeit Problematisch Kein Problem Kein Problem
Alpine.js-Implementierung Intersection Observer @click + loadNextPage Kein JS nötig

In E-Commerce-Projekten hat sich ein Hybrid bewährt: Infinite Scroll für mobile Geräte (Touch-Scrolling ist natürlich), Load-More-Button für Desktop. Alpine.js ermöglicht das mit einem einfachen Conditional: Auf Mobile wird der Intersection Observer registriert, auf Desktop erscheint stattdessen ein Button, der loadNextPage() aufruft. Dieselbe Lade-Logik, unterschiedliche UX-Auslöser.

Mironsoft

Alpine.js, Hyvä Themes und Magento 2 Frontend-Entwicklung

Infinite Scroll für euren Magento-Hyvä-Shop?

Wir implementieren Infinite Scroll und Load-More-Systeme für Hyvä Themes – SEO-freundlich, performance-optimiert und vollständig in Alpine.js integriert.

Kategorielisten

Infinite Scroll mit Magento REST API, URL-State und SEO-Fallback

Suchergebnisse

Infinite Scroll für Elasticsearch-basierte Suche mit Filter-Integration

Mobile-First

Adaptive UX: Infinite Scroll auf Mobile, Load-More auf Desktop

10. Zusammenfassung

Infinite Scroll mit Alpine.js und dem Intersection Observer ist ein klar strukturierbares, performantes Feature, das ohne externe Bibliothek auskommt. Der Intersection Observer feuert den Nachladeaufruf asynchron und nicht-blockierend, sobald das Sentinel-Element in den Viewport kommt. Alpine.js hält den gesamten State – Produktliste, aktuelle Seite, Ladezustand, Fehler – reaktiv und propagiert Änderungen sofort ins Template. Die History API synchronisiert die Browser-URL mit der aktuellen Scrollposition.

SEO ist die wichtigste Einschränkung: Infinite Scroll muss als Progressive Enhancement über paginierte Fallback-URLs implementiert werden. Google-Crawler sehen nur die erste Seite. Die sauberste Lösung für Magento ist Server-Rendering der ersten Seite und REST-API-basiertes Nachladen für folgende Seiten, mit Standard-Paginierungs-URLs als indexierbare Alternativen.

Alpine.js Infinite Scroll — Das Wichtigste auf einen Blick

Intersection Observer

Asynchron, nicht-blockierend. rootMargin: '200px' für frühzeitiges Triggern. threshold: 0. Sentinel-Element am Listenende beobachten. disconnect() in destroy().

State-Design

products[], currentPage, totalPages, loading, error. hasMore-Getter als einzige Quelle für alle Template-Conditions. REST-Response per push() anhängen.

URL & SEO

history.replaceState für URL-Sync. Paginierungs-URLs (?p=2) als SEO-Fallback. Erste Seite server-gerendert. REST nur für Folgeseiten.

UX-Zustände

Skeleton-Loader (animate-pulse) statt Spinner. Listenende-Meldung wenn !hasMore. Retry-Button bei error. Adaptive UX: IO auf Mobile, Button auf Desktop.

11. FAQ: Alpine.js Infinite Scroll

1Warum Intersection Observer statt scroll-Listener?
Intersection Observer ist asynchron und nicht-blockierend. Scroll-Listener laufen synchron auf dem Main Thread. Kein Performance-Overhead, einfachere Implementierung, automatisches Cleanup.
2Wie verhindere ich mehrfaches Feuern des Observers?
loading-Flag in der Observer-Callback-Bedingung: if (isIntersecting && hasMore && !loading). Während Anfrage läuft, werden weitere Observer-Callbacks ignoriert.
3Wie erkenne ich das Ende der Produktliste?
totalPages = Math.ceil(total_count / pageSize) aus REST-Response. hasMore-Getter: currentPage < totalPages. Wenn false: Sentinel ausblenden, Observer disconnecten, Listenende-Meldung anzeigen.
4Ist Infinite Scroll SEO-freundlich in Magento?
Nur als Progressive Enhancement. Erste Seite server-gerendert. Paginierte URLs (?p=2) als eigenständige, indexierbare Seiten zugänglich halten. Google crawlt kein JS-basiertes Nachladen.
5Wie aktualisiere ich die Browser-URL beim Scrollen?
history.replaceState(null, '', '?p=' + currentPage) nach jedem loadNextPage. Beim Reload liest Alpine den Parameter und startet auf der entsprechenden Seite.
6Wie verhindere ich Memory Leaks beim Observer?
Observer-Instanz in _observer speichern. In Alpine's destroy(): this._observer.disconnect(). Alpine ruft destroy() automatisch beim Entfernen der Komponente aus dem DOM auf.
7Wie rendere ich erste Seite server-seitig?
PHP serialisiert erste 12 Produkte als JSON ins Template. Alpine nimmt als config.initial. currentPage startet bei 1. Folgeseiten via REST, push() ans products-Array.
8Was ist ein Sentinel-Element?
Ein leeres div am Listenende: <div data-sentinel></div>. Der Observer beobachtet es. Wenn es in den Viewport kommt, wird loadNextPage ausgelöst. Unsichtbar, kein Layout-Einfluss.
9Wie implementiere ich Load-More-Button statt automatischem Scroll?
Kein Observer, kein Sentinel. Button mit @click="loadNextPage()" und :disabled="loading". loadNextPage-Methode identisch. Adaptive Variante: Observer auf Mobile, Button auf Desktop.
10Wie zeige ich einen Skeleton-Loader statt Spinner?
x-show="loading" auf Grid mit leeren Karten-Strukturen: animate-pulse bg-slate-200 rounded. Anzahl der Skeletons = pageSize. Kein Flackern durch gleiche Raumnahme wie echte Karten.