Infinite Scroll: Intersection Observer, React Query и SEO

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

Разработка и обслуживание любых видов сайтов:

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

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Услуги, которые мы предлагаем
Показано 1 из 1Все 2062 услуг
Infinite Scroll: 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 браузера, которое асинхронно отслеживает пересечение элемента с областью просмотра. Мы создаём невидимый элемент-маяк в конце списка и при его появлении запускаем загрузку следующей порции. Это эффективнее скролл-ивентов: не влияет на производительность, даёт точное срабатывание и потребляет меньше ресурсов 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