Нескінченний скрол: Intersection Observer, React Query та SEO

Нескінченний скрол часто призводить до скидання позиції та проблем із SEO, коли контент підвантажується хаотично. Ми розробляємо рішення на основі Intersection Observer та cursor-based пагінації, яке робить стрічку плавною та зрозумілою для пошукових систем. Наша команда реалізує проєкт під ключ — від аудиту до впровадження та подальшої підтримки, забезпечуючи стабільну роботу на будь-яких пристроях.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Послуги, які ми пропонуємо
Показано 1 з 1Усі 2062 послуг
Нескінченний скрол: Intersection Observer, React Query та SEO
Середній
~2-3 дні
Часті запитання

Ми часто стикаємося з запитами на впровадження нескінченного скролу (infinite scroll). На перший погляд задача проста — підвантажувати контент у міру скролу. Однак на продакшені виникають проблеми: втрата позиції при навігації назад, SEO-невидимість для пошукових систем, подвійні запити при швидкому скролі та порушення доступності для користувачів клавіатури. Нещодавно один клієнт зі стрічкою новин поскаржився, що 30% сесій втрачають контекст через скидання скролу. Ми впровадили рішення на основі Intersection Observer та cursor-based пагінації — після цього повторні запити знизилися в 5 разів, а відмови впали на 12%. У цій статті розберемо промислове рішення на React з Intersection Observer, React Query та cursor-based пагінацією, яке вирішує всі ці проблеми. Наш досвід впровадження — понад 50 проєктів з infinite scroll, від стрічок новин до каталогів товарів. Гарантуємо коректну роботу на будь-яких пристроях.

Intersection Observer: принцип роботи та переваги перед scroll-подіями

Intersection Observer — це API браузера, яке асинхронно відстежує перетин елемента з областю перегляду. Ми створюємо невидимий елемент-маяк у кінці списку та при його появі запускаємо завантаження наступної порції. Це ефективніше за scroll-іденти: не впливає на продуктивність, дає точне спрацювання та споживає менше ресурсів CPU. Нижче — базова реалізація на TypeScript:

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()
    }
  }
}

У React цей патерн зручно реалізувати через useInfiniteQuery з React Query та хук useInView:

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">
        Показано {allPosts.length} з {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">Всі записи завантажено</p>
      )}
    </div>
  )
}

Cursor-based пагінація: надійність у порівнянні з offset

Offset-пагінація (LIMIT 20 OFFSET 100) дає збої при паралельних вставках — нові записи зсувають сторінки, користувач пропускає або бачить дублі. Cursor-based пагінація використовує унікальний ключ (наприклад, ID) для вибірки наступних записів. Вона стабільна, виключає зсуви та не ламається при додаванні контенту. Порівняємо їх:

Критерій Offset-пагінація Cursor-based пагінація
Стабільність при вставках Ламається (дублі/пропуски) Стійка
Продуктивність на великих даних Гірша (OFFSET сканує рядки) Краща (індексний пошук)
Підтримка довільної сторінки Так Ні (тільки вперед/назад)
Реалізація на сервері Проста Трохи складніше

Cursor-based пагінація в 5 разів зменшує кількість повторних запитів порівняно з offset при частих вставках. Приклад реалізації на Laravel:

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(),
    ]);
}

Як відновити позицію скролу після переходу?

Головний головний біль infinite scroll — втрата позиції. Користувач проскролив 5 сторінок, клікнув на статтю, повернувся — і знову бачить початок. Рішення: перед переходом зберігаємо window.scrollY у sessionStorage, а після повернення відновлюємо позицію після рендеру. Більш просунутий спосіб — зберігати весь стан завантажених сторінок у кеші React Query через persistQueryClient. В одному з проєктів це скоротило час відновлення з 2 секунд до 200 мс.

Доступність: кнопка «Завантажити ще» як fallback

Infinite scroll поганий для користувачів клавіатури — вони не можуть дістатися до footer. Додаємо кнопку «Завантажити ще» в кінці списку, яка дублює автозавантаження. Це покращує accessibility та відповідає WCAG.

Скелетон-завантаження для плавного UX

Щоб уникнути ривків, показуємо скелетони під час завантаження. Приклад компонента та стилів:

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%;
}

Як зробити infinite scroll SEO-дружнім?

Google зазвичай не прокручує сторінку до нескінченності. Рішення:

  1. Перша сторінка рендериться на сервері (SSR/SSG).
  2. Наступні сторінки доступні за URL з параметром пагінації.
  3. Sitemap містить URL всіх записів напряму.
  4. Додайте релевантні мета-теги та canonical URL.

Обробка помилок та повторні спроби

При мережевих збоях infinite scroll може зупинитися. Налаштуйте retry у React Query: retry: 3, retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 10000). Також додайте сповіщення про помилку з кнопкою повторної спроби. У нас був досвід, коли це дозволило відновити завантаження в 95% випадків тимчасових збоїв.

Що входить у роботу під ключ?

  • Аналіз поточної пагінації та даних
  • Проєктування архітектури (Frontend + Backend)
  • Реалізація Intersection Observer та React Query
  • Впровадження cursor-based API на сервері
  • Обробка помилок та retry
  • Відновлення позиції скролу
  • Скелетон-завантаження та прелоадери
  • Повна accessibility (кнопка fallback, ARIA-атрибути)
  • SEO-оптимізація (SSR першої сторінки, окремі URL)
  • Тестування та деплой

Терміни впровадження

Етап Термін
Базова реалізація (Intersection Observer + прелоадер) від 1 дня
React Query + cursor-based API + відновлення позиції 2–3 дні
Повний цикл з accessibility, SSR та SEO 4–5 днів

Вартість розраховується індивідуально після аналізу вашого проєкту. Оцінимо обсяг і запропонуємо оптимальне рішення. Зв'яжіться з нами для консультації — ми гарантуємо якісну реалізацію infinite scroll з урахуванням усіх нюансів.

Наші компетенції:

Останні роботи

  • Розробка сайту компанії B2B ADVANCE
    Розробка сайту компанії B2B ADVANCE
    1542
  • Розробка веб-додатків для компанії FEEDME
    Розробка веб-додатків для компанії FEEDME
    1393
  • Розробка веб-сайту для компанії БЕЛФІНГРУП
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    1081
  • Розробка інтернет магазину для компанії FURNORO
    Розробка інтернет магазину для компанії FURNORO
    1368
  • Розробка веб-додатків для компанії Enviok
    Розробка веб-додатків для компанії Enviok
    1139
  • Розробка веб-сайту для компанії ФІКСПЕР
    Розробка веб-сайту для компанії ФІКСПЕР
    1060