Infinite Scroll SEO for Headless Product Listings

Infinite scroll is a client-side interaction Googlebot never triggers, so a headless product listing stays crawlable only when the scroll UX sits on top of real paginated URLs and server-rendered links.

When to Use This Approach

Reach for this pattern when your headless listing has any of these characteristics:

  • A product or blog listing uses infinite scroll for the user experience, and content beyond the first viewport is not showing up in the index.
  • You want the fluid scroll interaction but cannot afford to lose crawlability of deeper items in a large catalog.
  • Your analytics or log data show Googlebot only ever fetching the first batch of results, never the scroll-loaded pages.

Infinite scroll over a paginated foundation The server renders page one with anchor pagination; an IntersectionObserver fetches the next page URL as the user scrolls; History API pushState keeps the address bar mapped to a crawlable URL. Server HTML page 1 + anchors Observer fetch /page/N pushState URL = /page/N Crawler indexes Scroll is a progressive enhancement over crawlable /page/N URLs Googlebot follows the anchors; it never fires the scroll fetch

Implementation Steps

Step 1: Back the Scroll With Real Page URLs

Before adding any scroll behavior, make sure each batch of results is independently addressable at a path-based URL that returns full HTML when requested directly. This is the same crawlable foundation described in crawlable pagination without rel=next/prev.

// app/products/page/[page]/page.tsx — Next.js App Router
import { notFound } from 'next/navigation';

const PER_PAGE = 24;

export default async function ProductsPage({
  params,
}: {
  params: { page: string };
}) {
  const page = Number(params.page);
  if (!Number.isInteger(page) || page < 1) notFound();

  const products = await getProducts({ page, perPage: PER_PAGE });
  if (products.length === 0) notFound();

  const totalPages = await getProductPageCount(PER_PAGE);
  return <ProductGrid products={products} page={page} totalPages={totalPages} />;
}

Validation: curl -s https://staging.yourdomain.com/products/page/3/ | grep -c 'data-product-id' must return the per-page item count (24), proving the batch renders server-side.


Step 2: Progressively Enhance With IntersectionObserver

Layer the scroll behavior on top of the server-rendered grid. The client fetches the next page only after the first page and its links already exist in the DOM, so a crawler that ignores JavaScript still gets a complete first page.

// components/InfiniteGrid.tsx (client component)
'use client';
import { useEffect, useRef, useState } from 'react';

export function InfiniteGrid({ initialPage, totalPages }: { initialPage: number; totalPages: number }) {
  const [page, setPage] = useState(initialPage);
  const sentinel = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (page >= totalPages) return;
    const observer = new IntersectionObserver(async ([entry]) => {
      if (!entry.isIntersecting) return;
      const next = page + 1;
      const res = await fetch(`/api/products?page=${next}`);
      appendProducts(await res.json());
      setPage(next);
      history.pushState(null, '', `/products/page/${next}`); // Step 3
    });
    if (sentinel.current) observer.observe(sentinel.current);
    return () => observer.disconnect();
  }, [page, totalPages]);

  return <div ref={sentinel} aria-hidden="true" />;
}

Validation:

# The enhancement must not be required for content: raw HTML already has page 1
curl -s https://staging.yourdomain.com/products/page/1/ | grep -c 'data-product-id'
# Expected: 24 (content present without any client script running)

Step 3: Sync the History API on Scroll

As each new batch loads, update the address bar with history.pushState so the visible scroll state maps to a real crawlable URL. A user who copies the URL mid-scroll, or reloads, lands on a page that server-renders the content they were viewing.

// utility invoked from the observer callback in Step 2
function syncScrollUrl(page: number) {
  const url = page === 1 ? '/products' : `/products/page/${page}`;
  if (window.location.pathname !== url) {
    history.pushState({ page }, '', url);
  }
}

// Restore correct data when the user navigates back/forward
window.addEventListener('popstate', (e) => {
  const page = (e.state && e.state.page) || 1;
  loadPage(page);
});

Validation: Scroll to load page 3 in a real browser, then reload. The URL should read /products/page/3, and the server must return that page’s products directly.


Step 4: Server-Render an Anchor Pagination Fallback

Render standard <a href> pagination links in the initial HTML, even though scroll usually replaces them for humans. These anchors are how Googlebot walks from page to page without ever scrolling. Keep them visible or place them in a <noscript>-friendly footer rather than hiding them behind display:none only via script.

// components/ProductGrid.tsx — server component footer
export function PaginationFallback({ page, totalPages }: { page: number; totalPages: number }) {
  const href = (n: number) => (n === 1 ? '/products' : `/products/page/${n}`);
  return (
    <nav aria-label="Product pagination" className="pagination-fallback">
      {page > 1 && <a href={href(page - 1)} rel="prev">Previous page</a>}
      {page < totalPages && <a href={href(page + 1)} rel="next">Next page</a>}
    </nav>
  );
}

Validation:

curl -s https://staging.yourdomain.com/products/page/1/ \
  | grep -oP '<a[^>]+href="/products/page/2"'
# Must return the next-page anchor from the server HTML

Step 5: Validate Crawlability With JavaScript Disabled

The definitive test is fetching the raw HTML the way a crawler first sees it — before any script executes — and confirming both the first batch of items and the pagination anchors are present. This mirrors how you would diagnose render-blocking JavaScript that hides content behind hydration.

# Content check: first batch of items present in raw HTML
curl -s https://staging.yourdomain.com/products \
  | grep -c 'data-product-id'
# Expected: 24 — the first page renders without JS

# Link check: at least one crawlable next-page anchor exists
curl -s https://staging.yourdomain.com/products \
  | grep -oP '<a[^>]+href="/products/page/\d+"' | head -n1
# Expected: a real /products/page/2 anchor

SEO Impact Summary

Signal What improves What breaks if misconfigured
Discovery Anchor fallbacks let Googlebot crawl every page without scrolling Scroll-only loading hides all content past the first viewport
Indexation Each /page/N URL renders full content server-side Client-only fetches leave deep pages empty in the raw HTML
Shareability pushState keeps the URL mapped to real crawlable pages A static URL during scroll makes deep states unshareable and unindexable
Rendering cost Progressive enhancement keeps first paint light Loading everything client-side inflates LCP and TBT

Measurable signals to watch:

  • GSC Pages report: /products/page/N URLs move from “Discovered - currently not indexed” to “Indexed”.
  • Log analysis: Googlebot fetches deep page URLs, not just the first batch.
  • Rendered-vs-raw diff: the item count in raw HTML matches the per-page batch size.

Edge Cases and Gotchas

Scroll position on back navigation When a user follows a product then presses back, restoring both the scroll depth and the loaded batches is easy to get wrong. Persist the current page in history.state and rehydrate from the matching /page/N data on popstate, otherwise the user is dumped at the top of an empty grid.

Duplicate first batch after hydration If the client re-fetches page one on mount, users briefly see doubled items. Seed the client state from the server-rendered batch instead of fetching it again, and start the observer from initialPage + 1.

Filters and sort layered over scroll Faceted filters combined with infinite scroll create many URL states (/products/page/2 under a ?category= filter). Decide which combinations are indexable, canonicalize the rest, and keep the crawlable anchor fallback pointed at the canonical path sequence. The pagination SEO best practices for headless APIs guide covers stable offset and cursor handling for the data layer.

Hidden fallback links Do not strip the anchor pagination from the DOM with server-side display:none intended only for JS-enabled users. If the links are absent or hidden in a way that reads as cloaking, crawlers lose the path to deeper pages. Keep them present and reachable.

Frequently Asked Questions

Can Googlebot scroll infinite lists? No. Googlebot renders a page at a fixed viewport and does not scroll, click, or trigger scroll-driven fetches. Any items that only load in response to a scroll event are invisible to it unless the same content is reachable through crawlable /page/N URLs and server-rendered links.

How do I keep infinite scroll crawlable? Back the scroll UX with real path-based page URLs, server-render the first batch plus anchor pagination links, and use History API pushState to map the visible scroll position to a crawlable URL. Treat infinite scroll as a progressive enhancement layered on a fully paginated foundation rather than as the primary content-loading mechanism.

Do I need real pagination URLs behind infinite scroll? Yes. Real /page/N URLs are what Googlebot actually crawls and indexes. Without them, everything beyond the first viewport is undiscoverable because the scroll fetch is a client-side event the crawler never fires. The paginated URLs are the crawlable substrate; the scroll is just the human-facing interaction on top.

Does infinite scroll hurt Core Web Vitals? It can, if the first page loads a heavy client bundle or fetches everything up front. Keep the initial server render lean, defer the observer logic, and load batches only on demand so LCP and TBT stay within budget while deeper content remains crawlable through the anchor fallback.


Part of: Pagination Handling in Headless

Related