AACsearch
SDKsCookbook

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

ConcernInfinite scrollPaginated PLP
Browsing UXSmootherMore cognitive load
Deep linkingHard ("page 47" not meaningful)Trivial (?page=47)
SEOWorse — Googlebot does not scrollNative — every page indexable
FooterOften unreachableReachable
MemoryGrows unboundedBounded

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.

On this page