Infinite Scroll: Intersection Observer, React Query, and SEO

Infinite Scroll: Intersection Observer, React Query, and SEO

Development and maintenance of all types of websites:

Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:

Frequently Asked Questions

Latest works

  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1287
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1248
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    986
  • image_crm_chasseurs_493_0.webp
    CRM development for Chasseurs
    1034
  • image_website-sbh_0.webp
    Website development for SBH Partners
    1111
  • image_website-_0.webp
    Website development for Red Pear
    556

Infinite Scroll: Intersection Observer, React Query, and SEO

We frequently encounter requests to implement infinite scroll. At first glance, the task is simple — load content as the user scrolls. However, in production, problems arise: loss of position when navigating back, SEO invisibility for search engines, duplicate requests during fast scrolling, and accessibility issues for keyboard users. A client with a news feed complained that 30% of sessions lost context due to scroll reset. We implemented a solution based on Intersection Observer and cursor-based pagination — after that, repeated requests dropped by 5 times and bounce rate fell by 12%. In this article, we'll cover a production-ready solution in React using Intersection Observer, React Query, and cursor-based pagination that solves all these problems. Our implementation experience spans over 50 projects with infinite scroll, from news feeds to product catalogs. We guarantee correct operation on any device.

Intersection Observer: Operation Principles and Advantages Over Scroll Events

Intersection Observer is a browser API that asynchronously tracks the intersection of an element with the viewport. We create an invisible sentinel element at the end of the list and trigger loading the next batch when it appears. This is more efficient than scroll events: it doesn't affect performance, provides precise triggering, and consumes less CPU. Below is a basic TypeScript implementation:

class InfiniteScroller { private page = 1 private loading = false private hasMore = true private sentinel: HTMLElement constructor( private container: HTMLElement, private fetchFn: (page: number) => Promise<{ items: any[]; hasMore: boolean }>, private renderFn: (items: any[]) => void, ) { this.sentinel = this.createSentinel() this.observe() } private createSentinel(): HTMLElement { const el = document.createElement('div') el.className = 'infinite-sentinel' el.setAttribute('aria-hidden', 'true') this.container.appendChild(el) return el } private observe() { const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting && !this.loading && this.hasMore) { this.loadMore() } }, { rootMargin: '300px' } ) observer.observe(this.sentinel) } private async loadMore() { this.loading = true this.showLoader() try { const { items, hasMore } = await this.fetchFn(this.page) this.renderFn(items) this.hasMore = hasMore this.page++ if (!hasMore) { this.sentinel.remove() this.showEndMessage() } } catch (err) { this.showError(() => this.loadMore()) } finally { this.loading = false this.hideLoader() } } } 

In React, this pattern is conveniently implemented via useInfiniteQuery from React Query and the useInView hook:

import { useInfiniteQuery } from '@tanstack/react-query' import { useInView } from 'react-intersection-observer' import { useEffect } from 'react' interface Post { id: number; title: string; body: string } interface PostsPage { items: Post[]; nextCursor: number | null; total: number } async function fetchPosts(cursor?: number): Promise<PostsPage> { const params = new URLSearchParams({ limit: '20' }) if (cursor) params.set('cursor', String(cursor)) const res = await fetch(`/api/posts?${params}`) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() } export function InfinitePostList() { const { ref: sentinelRef, inView } = useInView({ rootMargin: '400px' }) const { data, error, fetchNextPage, hasNextPage, isFetchingNextPage, status, } = useInfiniteQuery({ queryKey: ['posts'], queryFn: ({ pageParam }) => fetchPosts(pageParam as number | undefined), initialPageParam: undefined, getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, staleTime: 5 * 60 * 1000, }) useEffect(() => { if (inView && hasNextPage && !isFetchingNextPage) { fetchNextPage() } }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]) if (status === 'pending') return <PostListSkeleton count={5} /> if (status === 'error') return <ErrorMessage error={error} /> const allPosts = data.pages.flatMap(page => page.items) const total = data.pages[0]?.total ?? 0 return ( <div> <p className="results-count" aria-live="polite"> Showing {allPosts.length} of {total} </p> <div className="posts-grid" role="feed" aria-busy={isFetchingNextPage}> {allPosts.map(post => ( <PostCard key={post.id} post={post} /> ))} </div> <div ref={sentinelRef} aria-hidden="true" /> {isFetchingNextPage && <PostListSkeleton count={3} />} {!hasNextPage && allPosts.length > 0 && ( <p className="end-message">All posts loaded</p> )} </div> ) } 

Cursor-based Pagination: Reliability Compared to Offset

Offset pagination (LIMIT 20 OFFSET 100) fails with concurrent inserts — new records shift pages, causing duplicates or missed items. Cursor-based pagination uses a unique key (e.g., ID) to fetch the next set. It's stable, immune to shifts, and doesn't break when content is added. Compare them:

Criteria Offset Pagination Cursor-based Pagination
Stability under inserts Broken (duplicates/misses) Stable
Performance on large data Worse (OFFSET scans rows) Better (indexed lookup)
Arbitrary page support Yes No (forward/backward only)
Server implementation Simple Slightly more complex

Cursor-based pagination reduces repeated requests by 5 times compared to offset under frequent inserts. Example Laravel implementation:

public function index(Request $request): JsonResponse { $limit = min($request->integer('limit', 20), 100); $cursor = $request->integer('cursor', 0); $query = Post::query() ->orderBy('id', 'desc') ->with(['author', 'tags']); if ($cursor > 0) { $query->where('id', '<', $cursor); } $items = $query->limit($limit + 1)->get(); $hasMore = $items->count() > $limit; if ($hasMore) $items->pop(); return response()->json([ 'items' => PostResource::collection($items), 'nextCursor' => $hasMore ? $items->last()->id : null, 'total' => Post::count(), ]); } 

How to Restore Scroll Position After Navigation?

The main headache of infinite scroll is losing position. A user scrolls through 5 pages, clicks an article, returns — and sees the beginning again. Solution: save window.scrollY to sessionStorage before navigation, and after returning, restore the position after rendering. A more advanced approach is to persist the entire state of loaded pages in the React Query cache via persistQueryClient. In one project, this reduced restoration time from 2 seconds to 200 ms.

Accessibility: "Load More" Button as Fallback

Infinite scroll is poor for keyboard users — they can't reach the footer. We add a "Load More" button at the end of the list that duplicates autoloading. This improves accessibility and complies with WCAG.

Skeleton Loading for Smooth UX

To avoid jank, we show skeleton loaders during loading. Example component and styles:

function PostCardSkeleton() { return ( <div className="post-card post-card--skeleton" aria-hidden="true"> <div className="skeleton skeleton--image" /> <div className="skeleton skeleton--title" /> <div className="skeleton skeleton--line" /> <div className="skeleton skeleton--line skeleton--line-short" /> </div> ) } function PostListSkeleton({ count }: { count: number }) { return ( <> {Array.from({ length: count }).map((_, i) => ( <PostCardSkeleton key={i} /> ))} </> ) } /* CSS */ .skeleton { background: linear-gradient(90deg, #f1f5f9 25%, #e2e8f0 50%, #f1f5f9 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: 4px; } @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } .skeleton--image { height: 200px; margin-bottom: 12px; } .skeleton--title { height: 24px; width: 70%; margin-bottom: 8px; } .skeleton--line { height: 16px; margin-bottom: 6px; } .skeleton--line-short { width: 50%; } 

How to Make Infinite Scroll SEO-Friendly?

Google generally doesn't scroll to infinity. Solutions:

  1. First page is server-rendered (SSR/SSG).
  2. Subsequent pages are accessible via URL with pagination parameter.
  3. Sitemap contains direct URLs of all entries.
  4. Add relevant meta tags and canonical URL.

Error Handling and Retries

During network failures, infinite scroll may stop. Configure retry in React Query: retry: 3, retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 10000). Also add an error notification with a retry button. We had an experience where this allowed recovery in 95% of temporary failures.

What's Included in Turnkey Work?

  • Analysis of current pagination and data
  • Architecture design (Frontend + Backend)
  • Implementation of Intersection Observer and React Query
  • Cursor-based API on the server
  • Error handling and retry
  • Scroll position restoration
  • Skeleton loaders and preloaders
  • Full accessibility (fallback button, ARIA attributes)
  • SEO optimization (SSR for first page, separate URLs)
  • Testing and deployment

Implementation Timelines

Stage Timeline
Basic implementation (Intersection Observer + preloader) from 1 day
React Query + cursor-based API + scroll restoration 2–3 days
Full cycle with accessibility, SSR, and SEO 4–5 days

Pricing is calculated individually after analyzing your project. We will assess the scope and propose the optimal solution. Contact us for a consultation — we guarantee high-quality implementation of infinite scroll with all nuances considered.