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.
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/NURLs 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
- Crawlable Pagination Without rel=next/prev in Headless — the path-based, self-canonical URL foundation that infinite scroll layers on top of
- ISR vs SSG vs CSR Routing — choose a rendering mode that keeps listing content in the server HTML crawlers parse first
- Dynamic Routing & Indexation Workflows — the parent reference covering routing, indexation, and URL architecture for headless stacks