Infinite scroll et SEO : le guide technique complet

Un site e-commerce de 18 000 produits passe d'une pagination classique à l'infinite scroll. Trois semaines plus tard, 70 % des pages produit ont disparu de l'index Google. Le crawl de Googlebot s'arrête aux 48 premiers produits de chaque catégorie — ceux chargés au premier rendu. Le reste n'existe plus pour le moteur.

Ce scénario n'est pas hypothétique. C'est le résultat mécanique d'un infinite scroll mal implémenté : le contenu chargé dynamiquement via JavaScript à l'événement scroll n'est jamais découvert par un crawler qui ne scrolle pas.

Pourquoi Googlebot ne scrolle pas (et ce que ça implique)

Il faut d'abord tuer une idée reçue tenace. Googlebot exécute JavaScript. Il utilise une version récente de Chrome (headless) via le Web Rendering Service (WRS). Mais exécuter JavaScript ne signifie pas simuler un comportement utilisateur complet.

Googlebot ne génère aucun événement de scroll. La documentation officielle de Google est explicite sur ce point : le viewport du WRS est fixé à 411×731 pixels (équivalent Moto G4), et le rendu produit un snapshot unique de la page à son état initial — plus les mutations DOM déclenchées par le chargement, les timers et les requestAnimationFrame, mais pas les interactions utilisateur comme le scroll, le clic ou le hover.

Source : Google Search Central — JavaScript SEO basics

Les conséquences directes

Si votre infinite scroll repose sur un IntersectionObserver lié à un sentinel element situé en bas de page, ou sur un listener scroll classique, le callback ne sera jamais déclenché lors du rendu par le WRS. Le contenu paginé au-delà du premier chargement est invisible.

Concrètement, sur une page catégorie qui affiche 24 produits en batch initial et charge les suivants à l'approche du bas de page :

  • Googlebot voit 24 produits sur potentiellement 800
  • Les liens internes vers les 776 produits restants n'existent pas dans le DOM rendu
  • Le PageRank interne ne se distribue pas vers ces produits
  • Les produits non liés finissent orphelins et sortent de l'index en quelques cycles de crawl

Le problème est aggravé si les URLs des produits ne sont découvrables que via ces liens dynamiques — aucun sitemap ne compensera l'absence de linking interne structurel.

Pour une compréhension complète des mécanismes de rendu JS côté Googlebot, consultez notre guide sur JavaScript et SEO.

Le pattern recommandé : infinite scroll + pagination statique en overlay

La solution n'est pas de choisir entre infinite scroll et pagination. C'est de servir les deux simultanément — un UX moderne pour l'utilisateur, une structure crawlable pour les moteurs.

Le principe : des URLs de pagination réelles

Chaque "page" de résultats de l'infinite scroll doit correspondre à une URL distincte et accessible. Google recommande explicitement ce pattern depuis 2014, et il reste la seule approche fiable.

Votre page catégorie /chaussures affiche les produits 1-24. Les URLs /chaussures?page=2, /chaussures?page=3, etc. doivent exister en tant que pages HTML complètes — rendues côté serveur — contenant chacune leur lot de 24 produits avec les liens correspondants.

Implémentation HTML : les liens <a> de pagination dans le DOM initial

Même si l'interface utilisateur masque visuellement la pagination classique au profit de l'infinite scroll, les liens doivent être présents dans le HTML servi au premier chargement :

<!-- Page catégorie /chaussures (page 1 implicite) -->
<div class="product-grid" id="product-list">
  <!-- 24 produits rendus côté serveur -->
  <a href="/chaussures/nike-air-max-90" class="product-card">
    <h3>Nike Air Max 90</h3>
    <span class="price">129,00 €</span>
  </a>
  <!-- ... 23 autres produits ... -->
</div>

<!-- Pagination HTML — visible ou masquée visuellement, mais DANS le DOM initial -->
<nav class="pagination" aria-label="Pagination des résultats">
  <span class="pagination__current" aria-current="page">1</span>
  <a href="/chaussures?page=2" class="pagination__link">2</a>
  <a href="/chaussures?page=3" class="pagination__link">3</a>
  <a href="/chaussures?page=4" class="pagination__link">4</a>
  <!-- ... -->
  <a href="/chaussures?page=34" class="pagination__link">34</a>
  <a href="/chaussures?page=2" class="pagination__next" rel="next">Suivant</a>
</nav>

<!-- Sentinel pour l'infinite scroll côté client -->
<div id="scroll-sentinel" aria-hidden="true"></div>

Point critique : ne masquez pas cette pagination avec display: none ou visibility: hidden appliqué dès le HTML initial. Googlebot peut ignorer du contenu masqué par CSS. La bonne approche : servir la pagination visible dans le HTML, puis la masquer via JavaScript côté client une fois l'infinite scroll initialisé. Ainsi, Googlebot (qui ne déclenche pas toujours le JS, ou qui priorise le HTML initial) voit les liens, et l'utilisateur avec JS activé obtient l'infinite scroll.

Le JavaScript côté client

// infinite-scroll.ts — Pattern production-ready
class InfiniteScrollManager {
  private sentinel: HTMLElement;
  private observer: IntersectionObserver;
  private currentPage: number = 1;
  private totalPages: number;
  private isLoading: boolean = false;
  private productList: HTMLElement;
  private paginationNav: HTMLElement;

  constructor(config: {
    totalPages: number;
    apiEndpoint: string;
    batchSize: number;
  }) {
    this.totalPages = config.totalPages;
    this.productList = document.getElementById('product-list')!;
    this.paginationNav = document.querySelector('.pagination')!;
    this.sentinel = document.getElementById('scroll-sentinel')!;

    // Masquer la pagination classique une fois le JS chargé
    // Googlebot voit la pagination; l'utilisateur voit l'infinite scroll
    this.paginationNav.setAttribute('aria-hidden', 'true');
    this.paginationNav.style.position = 'absolute';
    this.paginationNav.style.left = '-9999px';
    // NB : on ne met PAS display:none — on déplace hors écran.
    // Certains crawlers respectent display:none, ce qui casserait le fallback.

    this.observer = new IntersectionObserver(
      (entries) => this.handleIntersect(entries),
      { rootMargin: '400px' } // Pré-charge 400px avant le bas
    );
    this.observer.observe(this.sentinel);

    // Mettre à jour l'URL au scroll (History API)
    this.setupHistoryUpdates();
  }

  private async handleIntersect(entries: IntersectionObserverEntry[]) {
    const entry = entries[0];
    if (!entry.isIntersecting || this.isLoading || this.currentPage >= this.totalPages) {
      return;
    }

    this.isLoading = true;
    this.currentPage++;

    try {
      const response = await fetch(
        `/api/products?category=chaussures&page=${this.currentPage}&limit=24`,
        { headers: { 'Accept': 'application/json' } }
      );
      const data = await response.json();

      // Injecter les produits dans le DOM
      const fragment = document.createDocumentFragment();
      for (const product of data.products) {
        const card = this.createProductCard(product);
        fragment.appendChild(card);
      }
      // Insérer AVANT le sentinel
      this.productList.insertBefore(fragment, this.sentinel);

      // Mettre à jour l'URL sans recharger la page
      const newUrl = this.currentPage > 1
        ? `/chaussures?page=${this.currentPage}`
        : '/chaussures';
      history.replaceState({ page: this.currentPage }, '', newUrl);

    } catch (error) {
      console.error('Infinite scroll load failed:', error);
      // Fallback : réafficher la pagination classique
      this.paginationNav.style.position = 'static';
      this.paginationNav.removeAttribute('aria-hidden');
      this.observer.disconnect();
    } finally {
      this.isLoading = false;
    }
  }

  private setupHistoryUpdates() {
    // Permettre le deep-link : si l'URL contient ?page=5,
    // charger les pages 2 à 5 au démarrage
    const params = new URLSearchParams(window.location.search);
    const startPage = parseInt(params.get('page') || '1', 10);
    if (startPage > 1) {
      this.loadPagesUpTo(startPage);
    }
  }

  private async loadPagesUpTo(targetPage: number) {
    for (let p = 2; p <= targetPage; p++) {
      this.currentPage = p - 1;
      this.isLoading = false;
      await this.handleIntersect([{
        isIntersecting: true
      } as IntersectionObserverEntry]);
    }
  }

  private createProductCard(product: {
    url: string; name: string; price: number; image: string;
  }): HTMLAnchorElement {
    const a = document.createElement('a');
    a.href = product.url;
    a.className = 'product-card';
    a.innerHTML = `
      <img src="${product.image}" alt="${product.name}" loading="lazy" />
      <h3>${product.name}</h3>
      <span class="price">${product.price.toFixed(2)} €</span>
    `;
    return a;
  }
}

// Initialisation
document.addEventListener('DOMContentLoaded', () => {
  new InfiniteScrollManager({
    totalPages: 34,
    apiEndpoint: '/api/products',
    batchSize: 24,
  });
});

Deux détails importants dans ce code. Premièrement, la pagination HTML est déplacée hors écran (et non supprimée du DOM) quand le JS prend le relais. Deuxièmement, history.replaceState met à jour l'URL au fur et à mesure du scroll — ce qui permet le partage de deep-links et, surtout, donne un signal à Google sur la structure paginée.

La variante SSR : Next.js, Nuxt et le rendu hybride

Sur un SPA React ou Vue.js, le problème de l'infinite scroll se cumule avec celui du rendu client. La solution passe par un rendu hybride : SSR pour le HTML initial (avec pagination), hydratation côté client pour l'infinite scroll.

Exemple avec Next.js App Router

// app/chaussures/page.tsx
import { ProductGrid } from '@/components/ProductGrid';
import { Pagination } from '@/components/Pagination';
import { InfiniteScrollWrapper } from '@/components/InfiniteScrollWrapper';

interface Props {
  searchParams: { page?: string };
}

export default async function ChaussuresPage({ searchParams }: Props) {
  const currentPage = parseInt(searchParams.page || '1', 10);
  const pageSize = 24;

  // Fetch côté serveur — ce HTML sera dans la réponse initiale
  const { products, totalPages, totalProducts } = await fetchProducts({
    category: 'chaussures',
    page: currentPage,
    limit: pageSize,
  });

  return (
    <main>
      <h1>Chaussures ({totalProducts} produits)</h1>

      {/*
        InfiniteScrollWrapper est un Client Component qui :
        1. Rend les produits SSR en HTML initial
        2. S'hydrate et active l'infinite scroll côté client
        3. Conserve la pagination HTML dans le DOM pour les crawlers
      */}
      <InfiniteScrollWrapper
        initialProducts={products}
        currentPage={currentPage}
        totalPages={totalPages}
        category="chaussures"
      />

      {/* Pagination statique — toujours dans le HTML SSR */}
      <Pagination
        currentPage={currentPage}
        totalPages={totalPages}
        basePath="/chaussures"
      />
    </main>
  );
}

// Metadata dynamique
export async function generateMetadata({ searchParams }: Props) {
  const page = parseInt(searchParams.page || '1', 10);
  const suffix = page > 1 ? ` — Page ${page}` : '';
  return {
    title: `Chaussures${suffix} | MonSite`,
    description: `Découvrez notre collection de chaussures${suffix}. Livraison gratuite dès 50 €.`,
    alternates: {
      canonical: page === 1
        ? 'https://monsite.fr/chaussures'
        : `https://monsite.fr/chaussures?page=${page}`,
    },
  };
}

Le point essentiel : chaque URL ?page=N retourne un document HTML complet, rendu côté serveur, avec les produits correspondants et la navigation de pagination. L'infinite scroll ne s'active qu'après hydratation côté client. Si le JS échoue ou si le client est un crawler, la pagination HTML classique prend le relais.

Pour une comparaison détaillée des stratégies SSR/CSR et leur impact sur l'indexation, voir notre analyse des divergences SSR/CSR.

Les canonicals et le piège de la déduplication

Lorsque vous avez /chaussures, /chaussures?page=2, /chaussures?page=3, etc., chaque URL doit porter sa propre canonical auto-référente. C'est un point sur lequel beaucoup de développeurs se trompent.

Ce qu'il ne faut PAS faire

Pointer toutes les canonicals vers la page 1 :

<!-- /chaussures?page=5 — MAUVAIS -->
<link rel="canonical" href="https://monsite.fr/chaussures" />

Ce pattern signale à Google que la page 5 est un duplicata de la page 1. Google ignorera la page 5 et tous les liens internes qu'elle contient — exactement le problème qu'on cherche à éviter.

Ce qu'il faut faire

<!-- /chaussures?page=5 — CORRECT -->
<link rel="canonical" href="https://monsite.fr/chaussures?page=5" />

Chaque page paginée est un document distinct avec un contenu distinct. Elle mérite sa propre canonical.

Et rel="prev" / rel="next" ?

Google a officiellement annoncé en 2019 qu'il n'utilisait plus rel="prev" et rel="next" comme signal de pagination. Bing, en revanche, le supporte toujours. Ajoutez-les si ça ne coûte rien à votre build, mais ne comptez pas dessus pour Google.

Ce qui compte réellement pour Google : la structure de liens. Chaque page paginée doit lier vers la précédente et la suivante. La page 1 doit être atteignable depuis n'importe quelle page via la navigation de pagination. Et idéalement, votre architecture de site permet d'atteindre n'importe quelle page de pagination en 3 clics maximum depuis la homepage.

Scénario concret : migration d'un e-commerce de 15 000 produits

Le contexte

Un e-commerce mode avec 15 200 fiches produit, réparties dans 42 catégories. Les pages catégorie utilisent une pagination classique (24 produits/page, soit ~634 pages de pagination au total). Le site migre vers un frontend Next.js avec infinite scroll pour moderniser l'UX.

Avant la migration

  • 14 800 fiches produit indexées (97 %)
  • Pages de pagination crawlées régulièrement (logs serveur : ~2 000 hits Googlebot/jour sur les pages ?page=N)
  • Distribution du crawl : 40 % pages catégorie/pagination, 55 % fiches produit, 5 % autres

La migration mal exécutée (semaines 1-3)

L'équipe front remplace la pagination par un infinite scroll pur — les URLs ?page=N n'existent plus, tout passe par une API JSON chargée au scroll. La navigation de pagination HTML disparaît.

Résultat en 3 semaines :

  • Googlebot ne crawle plus que les 42 pages catégorie (1 par catégorie), au lieu de 634 pages de pagination
  • Chaque page catégorie ne contient que 24 liens produit dans le DOM rendu (au lieu de 24 × 15 pages en moyenne accessibles via la pagination)
  • 10 500 fiches produit perdent tout lien interne crawlable
  • L'index chute à 5 200 fiches (de 14 800, soit -65 %)
  • Le trafic organique sur les fiches produit baisse de 58 % en 4 semaines

Les signaux d'alerte étaient visibles dans les logs serveur : le volume de crawl Googlebot avait chuté de 2 000 à 300 hits/jour dès la première semaine. Un outil de monitoring comme Seogard aurait détecté la chute du nombre de pages indexées et l'apparition massive de soft 404 dans les 48 premières heures.

La correction (semaine 4)

Application du pattern décrit plus haut :

  1. Restauration des URLs /categorie?page=N avec rendu SSR complet
  2. Ajout de la navigation de pagination HTML dans le DOM initial
  3. Infinite scroll en surcouche JavaScript côté client
  4. Canonical auto-référentes sur chaque page de pagination
  5. Ajout des URLs de pagination dans le sitemap XML (optionnel mais accélère la redécouverte)

Après correction (semaines 5-10)

  • Crawl remonte à 1 800 hits/jour en semaine 6
  • Index remonte à 13 900 fiches en semaine 8
  • Retour au trafic organique d'origine en semaine 10

L'ensemble du processus a coûté 6 semaines de trafic. Sur un site avec un panier moyen de 85 € et un taux de conversion organique de 2,1 %, les 58 % de trafic perdu sur les fiches produit représentent un manque à gagner significatif chaque jour.

Vérifier et monitorer son implémentation

Avec Chrome DevTools

Le test le plus rapide pour vérifier ce que Googlebot verra :

  1. Ouvrez votre page catégorie dans Chrome
  2. Ouvrez DevTools → onglet Network
  3. Désactivez JavaScript (Settings → Debugger → Disable JavaScript)
  4. Rechargez la page

Si la pagination HTML et les liens produit de la première page apparaissent : votre fallback fonctionne. Si vous voyez une page vide ou un spinner : vous avez un problème.

Avec Screaming Frog

Configurez Screaming Frog en mode JavaScript rendering pour simuler le comportement de Googlebot :

  1. Configuration → Spider → Rendering → JavaScript
  2. Crawlez votre site
  3. Comparez le nombre de liens internes découverts par page catégorie en mode HTML vs JavaScript
  4. Si le mode HTML montre significativement moins de liens : votre pagination HTML n'est pas dans le DOM initial

Vérifiez aussi l'onglet Pagination pour confirmer que les URLs ?page=N sont bien crawlées et retournent un status 200.

Avec Google Search Console

Trois points de contrôle :

  • Couverture de l'index : surveillez le ratio pages indexées / pages soumises. Une chute brutale après un déploiement infinite scroll est le signal d'alarme principal.
  • Inspection d'URL : testez une URL ?page=5 de pagination. Vérifiez que le "HTML rendu" contient bien les liens produit et la navigation de pagination.
  • Rapport d'exploration : dans les statistiques de crawl, vérifiez que Googlebot continue à crawler les URLs de pagination. Si les hits sur ?page=N tombent à zéro, le maillage est cassé.

Avec l'analyse de logs

Le test le plus fiable reste l'analyse directe des logs serveur. Filtrez les requêtes de Googlebot sur les URLs de pagination :

# Extraire les hits Googlebot sur les pages de pagination
grep -i "googlebot" access.log \
  | grep -E "\?page=[0-9]+" \
  | awk '{print $7}' \
  | sort \
  | uniq -c \
  | sort -rn \
  | head -50

Si cette commande retourne peu ou pas de résultats après votre mise en production, Googlebot ne découvre pas vos pages de pagination — et donc pas les produits qu'elles lient.

Les edge cases à anticiper

Navigation à facettes + infinite scroll

Si vos pages catégorie combinent navigation à facettes et infinite scroll, la complexité explose. Chaque combinaison de filtres peut théoriquement générer une séquence de pagination distincte. La règle : seules les facettes SEO-friendly (celles que vous voulez indexer) doivent produire des URLs de pagination crawlables. Les autres combinaisons de filtres doivent utiliser noindex ou être bloquées via robots.txt / directive data-nosnippet.

Tri et ordre des produits

Si l'utilisateur change l'ordre de tri (prix croissant, popularité, nouveauté), l'infinite scroll recharge les produits dans un ordre différent. L'URL de pagination doit-elle refléter le tri ? Non — sauf si vous avez une raison SEO forte d'indexer un tri spécifique. La pagination crawlable doit utiliser un tri par défaut stable (pertinence, popularité). Les autres tris sont gérés côté client sans générer de nouvelles URLs.

Pages de pagination profondes

Un catalogue de 800 produits à 24/page génère 34 pages de pagination. C'est gérable. Mais un catalogue de 50 000 produits à 24/page génère 2 084 pages. Le crawl budget devient un enjeu.

Pour les catalogues très larges, limitez la profondeur de pagination exposée aux crawlers. Affichez les liens vers les pages 1-5 et la dernière page dans le HTML. Utilisez des sous-catégories pour segmenter le catalogue et réduire le nombre de pages par séquence de pagination. Une structure d'URLs bien pensée et un maillage par catégories sont plus efficaces que 2 000 pages de pagination.

Le cas "Load More" button vs true infinite scroll

Un bouton "Voir plus" qui charge le batch suivant sans scroll automatique pose le même problème qu'un infinite scroll : si le clic ne génère pas de nouvelle URL et que les produits sont chargés via JS, Googlebot ne les verra pas. Le pattern de solution est identique — pagination HTML en fallback, URLs ?page=N crawlables.

La seule nuance : le bouton "Voir plus" est parfois implémenté comme un <a href="/chaussures?page=2"> progressivement amélioré en JS. Dans ce cas, même sans JavaScript, le lien fonctionne comme une pagination classique. C'est le pattern le plus robuste et le plus simple à implémenter.

Le rôle du sitemap XML dans la pagination

Le sitemap ne remplace pas le linking interne — il complète la découverte. Si vos pages de pagination sont correctement liées entre elles et depuis les catégories, Googlebot les trouvera sans sitemap.

Cependant, après une migration ou une correction, inclure temporairement les URLs de pagination dans le sitemap accélère la redécouverte. Une fois le crawl stabilisé (vérifiable dans les logs), vous pouvez les retirer pour ne garder que les URLs à forte valeur (fiches produit, catégories).

Ne mettez jamais dans le sitemap une URL que vous ne voulez pas indexer. Si vos pages ?page=N portent un noindex (ce qui est un choix légitime dans certaines architectures), ne les ajoutez pas au sitemap — le signal contradictoire dégrade la confiance de Google dans votre sitemap.


L'infinite scroll n'est pas incompatible avec le SEO. Mais il exige une implémentation en deux couches : une structure de pagination HTML statique, crawlable et complète dans le DOM initial — et une surcouche JavaScript qui transforme cette pagination en expérience fluide pour l'utilisateur. Toute implémentation qui supprime les URLs de pagination ou qui conditionne l'existence des liens au scroll détruira silencieusement votre indexation. Un monitoring continu avec Seogard permet de détecter ces régressions dès le premier cycle de crawl, avant qu'elles n'impactent le trafic.

Articles connexes

Architecture9 avril 2026

Headless CMS et SEO : risques techniques et architectures viables

Architectures headless CMS décryptées côté SEO : SSR, ISR, gestion des meta, crawl budget. Guide technique avec exemples de code et scénarios réels.

Architecture9 avril 2026

API-first et SEO : rendre le contenu crawlable

Patterns techniques pour servir du contenu SEO-friendly depuis une architecture API-first : SSR, ISR, stale-while-revalidate et monitoring.

Architecture6 avril 2026

Architecture flat vs deep : profondeur de clic et crawl SEO

Flat ou deep structure ? Analyse technique de l'impact de la profondeur de clic sur le crawl budget, l'indexation et le maillage interne.