Infinite scroll
Load-more pagination using TanStack Query's `useInfiniteQuery` and an IntersectionObserver sentinel — one round-trip per scroll, no spinners-of-spinners.
Infinite scroll is best when the user is browsing rather than searching for a specific item. This recipe uses useInfiniteQuery for state management and an IntersectionObserver for the scroll trigger — no scroll event listeners, no manual debounce.
What you build
A <ProductFeed> component that:
- Loads 24 products on mount.
- Loads the next 24 when the user scrolls within ~400 px of the bottom.
- Stops loading when
hits.length >= found. - Recovers cleanly from network errors.
Component
"use client";
import { useEffect, useRef } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { SearchClient } from "@aacsearch/client";
const client = new SearchClient({
baseUrl: process.env.NEXT_PUBLIC_AACSEARCH_BASE_URL!,
apiKey: process.env.NEXT_PUBLIC_AACSEARCH_SEARCH_KEY!,
indexSlug: "products",
});
const PER_PAGE = 24;
export function ProductFeed({ filterBy }: { filterBy?: string }) {
const sentinelRef = useRef<HTMLDivElement>(null);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status, error } =
useInfiniteQuery({
queryKey: ["product-feed", filterBy ?? ""],
initialPageParam: 1,
queryFn: ({ pageParam, signal }) =>
client.search(
{
q: "*",
filterBy,
page: pageParam,
perPage: PER_PAGE,
sortBy: "popularity:desc",
},
{ signal },
),
getNextPageParam: (lastPage, allPages) => {
const loadedSoFar = allPages.reduce((sum, p) => sum + p.hits.length, 0);
return loadedSoFar < lastPage.found ? allPages.length + 1 : undefined;
},
});
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ rootMargin: "400px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
if (status === "pending") return <SkeletonGrid />;
if (status === "error") return <ErrorBanner error={error} />;
const allHits = data.pages.flatMap((p) => p.hits);
return (
<>
<div className="product-grid">
{allHits.map((hit) => (
<ProductCard key={hit.document.id} product={hit.document} />
))}
</div>
<div ref={sentinelRef} className="sentinel" aria-hidden="true">
{isFetchingNextPage && <LoadingSpinner />}
{!hasNextPage && allHits.length > 0 && <p>You've reached the end.</p>}
</div>
</>
);
}Why IntersectionObserver and not onScroll
IntersectionObserver runs off the main thread and only fires when the sentinel enters the viewport. A scroll listener fires up to 60 times per second and forces you to debounce or throttle. No contest.
The rootMargin: "400px" triggers the next fetch ~400 px before the user actually hits the bottom — the new page often arrives before they notice they were near the end.
Restoring scroll position on back-navigation
By default, returning to the page resets the scroll. To restore:
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export function useScrollRestore(key: string) {
useEffect(() => {
const saved = sessionStorage.getItem(`scroll:${key}`);
if (saved) window.scrollTo(0, Number(saved));
const onScroll = () => {
sessionStorage.setItem(`scroll:${key}`, String(window.scrollY));
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, [key]);
}Combined with TanStack Query's cache (staleTime set high), the user lands on the same scroll position and the same data after they back-navigate from a product detail page.
useInfiniteQuery({
queryKey: ["product-feed", filterBy],
staleTime: 5 * 60 * 1000, // 5 minutes — feed is "fresh enough"
gcTime: 10 * 60 * 1000,
// ...
});Trade-offs vs paginated PLP
| Concern | Infinite scroll | Paginated PLP |
|---|---|---|
| Browsing UX | Smoother | More cognitive load |
| Deep linking | Hard ("page 47" not meaningful) | Trivial (?page=47) |
| SEO | Worse — Googlebot does not scroll | Native — every page indexable |
| Footer | Often unreachable | Reachable |
| Memory | Grows unbounded | Bounded |
For PLP, paginate. For "browse" / "feed" surfaces, infinite scroll. Mix: paginated for SEO + an "infinite" enhancement on the client only after the first page renders.
Memory: virtualize past N pages
Past ~5 pages (120 cards), DOM nodes start to hurt. Use react-virtual or tanstack-virtual to recycle off-screen rows:
import { useVirtualizer } from "@tanstack/react-virtual";
const virtualizer = useVirtualizer({
count: allHits.length,
getScrollElement: () => containerRef.current,
estimateSize: () => 320,
overscan: 5,
});Virtualization disables CSS animations on rows that scroll off-screen. If your design relies on hover or focus animations, scope them to within the visible window.
Related
- Product listing page — paginated alternative
- Faceted search
- Filter, sort, pagination
Product listing page
A server-rendered ecommerce PLP with filters, sort, pagination, and SEO-friendly URLs. Combines the faceted search and pagination patterns into a complete page.
Click analytics
Track which results users actually click and which queries lead to conversions — feeds the AACsearch relevance tuner and your own funnel dashboards.