Paginacja w Next.js — offset vs cursor, infinite scroll, URL-based z searchParams

Opublikowano
11 kwietnia 2026
Aktualizacja
25 maja 2026
Czas czytania
4 min czytania

Trzy rodzaje paginacji

1. Numeryczna (URL-based) — /blog?page=3

Klasyczna paginacja z numerami stron. Użytkownik widzi, ile jest stron, i przeskakuje między nimi dowolnie. To wariant, który SEO lubi najbardziej, bo każda strona ma własny, stabilny URL — indeksowalny, udostępnialny i taki, do którego wrócisz zakładką. Domyślny wybór dla blogów, katalogów i portfolio.

2. Infinite scroll — ładuj więcej przy scrollu

Lista rośnie automatycznie, gdy użytkownik scrolluje. Popularne w social media i feedach. Gorsze dla SEO (jedna strona, dynamiczne ładowanie) i nawigacji (trudno wrócić do konkretnego elementu).

3. „Load more" button — hybrid

Przycisk „Załaduj więcej" na dole listy. Użytkownik kontroluje ładowanie. Dobry kompromis między UX a SEO.

URL-based paginacja — rekomendowana dla SEO

Server Component z searchParams

Code
// app/blog/page.tsx
import Link from 'next/link'
 
const POSTS_PER_PAGE = 10
 
export default async function BlogPage({
  searchParams,
}: {
  searchParams: Promise<{ page?: string }>
}) {
  const params = await searchParams
  const currentPage = Math.max(1, Number(params.page) || 1)
 
  const { posts, totalPosts } = await getPaginatedPosts({
    page: currentPage,
    perPage: POSTS_PER_PAGE,
  })
 
  const totalPages = Math.ceil(totalPosts / POSTS_PER_PAGE)
 
  return (
    <main>
      <h1>Blog</h1>
 
      <div className="space-y-6">
        {posts.map((post) => (
          <article key={post.id}>
            <h2>{post.title}</h2>
            <p>{post.excerpt}</p>
          </article>
        ))}
      </div>
 
      <Pagination
        currentPage={currentPage}
        totalPages={totalPages}
        basePath="/blog"
      />
    </main>
  )
}

Komponent paginacji

Code
// components/pagination.tsx
import Link from 'next/link'
 
interface PaginationProps {
  currentPage: number
  totalPages: number
  basePath: string
}
 
export function Pagination({
  currentPage,
  totalPages,
  basePath,
}: PaginationProps) {
  if (totalPages <= 1) return null
 
  function getPageUrl(page: number) {
    if (page === 1) return basePath // Pierwsza strona bez ?page=
    return `${basePath}?page=${page}`
  }
 
  // Generuj widoczne numery stron (max 7)
  function getPageNumbers(): (number | '...')[] {
    if (totalPages <= 7) {
      return Array.from({ length: totalPages }, (_, i) => i + 1)
    }
 
    if (currentPage <= 3) return [1, 2, 3, 4, '...', totalPages]
    if (currentPage >= totalPages - 2)
      return [
        1,
        '...',
        totalPages - 3,
        totalPages - 2,
        totalPages - 1,
        totalPages,
      ]
    return [
      1,
      '...',
      currentPage - 1,
      currentPage,
      currentPage + 1,
      '...',
      totalPages,
    ]
  }
 
  return (
    <nav
      aria-label="Paginacja"
      className="mt-12 flex items-center justify-center gap-1"
    >
      {currentPage > 1 && (
        <Link
          href={getPageUrl(currentPage - 1)}
          className="rounded-lg px-3 py-2 hover:bg-gray-100"
          aria-label="Poprzednia strona"
        >

        </Link>
      )}
 
      {getPageNumbers().map((page, i) =>
        page === '...' ? (
          <span key={`dots-${i}`} className="px-3 py-2">
            ...
          </span>
        ) : (
          <Link
            key={page}
            href={getPageUrl(page)}
            className={`rounded-lg px-3 py-2 ${
              page === currentPage
                ? 'bg-blue-600 text-white'
                : 'hover:bg-gray-100'
            }`}
            aria-current={page === currentPage ? 'page' : undefined}
          >
            {page}
          </Link>
        ),
      )}
 
      {currentPage < totalPages && (
        <Link
          href={getPageUrl(currentPage + 1)}
          className="rounded-lg px-3 py-2 hover:bg-gray-100"
          aria-label="Następna strona"
        >

        </Link>
      )}
    </nav>
  )
}

SEO — canonical dla paginacji

Code
// app/blog/page.tsx
import type { Metadata } from 'next'
 
export async function generateMetadata({
  searchParams,
}: {
  searchParams: Promise<{ page?: string }>
}): Promise<Metadata> {
  const params = await searchParams
  const page = Number(params.page) || 1
  const baseUrl = 'https://strivelab.pl/blog'
 
  return {
    title:
      page === 1 ? 'Blog — StriveLab' : `Blog — Strona ${page} — StriveLab`,
    alternates: {
      canonical: page === 1 ? baseUrl : `${baseUrl}?page=${page}`,
    },
  }
}

Google nie używa już rel="prev" / rel="next" jako sygnału indeksowania serii, więc nie próbuj generować ich przez metadata.other — to pole tworzy meta tagi, nie linki relacyjne. Najważniejsze są stabilne URL-e, poprawny canonical, linkowanie wewnętrzne i brak duplikatów.

Offset vs Cursor — zapytania do bazy

Offset pagination

Code
// Proste, ale wolne przy dużych datasetach
async function getPaginatedPosts({
  page,
  perPage,
}: {
  page: number
  perPage: number
}) {
  const offset = (page - 1) * perPage
 
  const [posts, totalPosts] = await Promise.all([
    db.post.findMany({
      where: { published: true },
      orderBy: { createdAt: 'desc' },
      skip: offset,
      take: perPage,
    }),
    db.post.count({ where: { published: true } }),
  ])
 
  return { posts, totalPosts }
}

Problem: wymaga od bazy danych przeskoczenia 10 000 rekordów. Przy milionach wierszy — wolne.

Cursor pagination

Code
// Szybkie nawet przy milionach rekordów
async function getPaginatedPosts({
  cursor,
  perPage,
}: {
  cursor?: string // ID ostatniego elementu z poprzedniej strony
  perPage: number
}) {
  const posts = await db.post.findMany({
    where: { published: true },
    orderBy: { createdAt: 'desc' },
    take: perPage + 1, // Pobierz +1 żeby wiedzieć, czy jest następna strona
    ...(cursor && {
      cursor: { id: cursor },
      skip: 1, // Pomiń sam cursor
    }),
  })
 
  const hasMore = posts.length > perPage
  const items = hasMore ? posts.slice(0, -1) : posts
  const nextCursor = hasMore ? items[items.length - 1].id : null
 
  return { posts: items, nextCursor, hasMore }
}

nie pozwala przeskakiwać do strony 50 — wymaga sekwencyjnego przechodzenia. Idealne dla infinite scroll, gorsze dla numerycznej paginacji.

Kiedy co?

KryteriumOffsetCursor
Przeskakiwanie do strony NTakNie
Wydajność przy dużych zbiorachWolneSzybkie
„Ile jest stron?"Łatwe (COUNT)Trudne
Infinite scrollMożliweIdealne
SEO (numeryczne URL)NatywneWymaga obejścia

Rekomendacja: offset dla blogów i katalogów, gdzie potrzebujesz numerów stron i skakania do konkretnej strony. Cursor dla feedów, logów i dużych zbiorów z infinite scroll, gdzie ważniejsza jest stabilność i wydajność niż numer strony.

Infinite scroll z Intersection Observer

Infinite scroll najczyściej zbudujesz na — elementem-wartownikiem na końcu listy, którego pojawienie się w widoku uruchamia doładowanie kolejnej porcji.

Code
// components/infinite-list.tsx
'use client'
 
import { useEffect, useRef, useState, useCallback } from 'react'
 
interface InfiniteListProps<T> {
  initialItems: T[]
  initialCursor: string | null
  fetchMore: (
    cursor: string,
  ) => Promise<{ items: T[]; nextCursor: string | null }>
  renderItem: (item: T) => React.ReactNode
}
 
export function InfiniteList<T extends { id: string }>({
  initialItems,
  initialCursor,
  fetchMore,
  renderItem,
}: InfiniteListProps<T>) {
  const [items, setItems] = useState(initialItems)
  const [cursor, setCursor] = useState(initialCursor)
  const [isLoading, setIsLoading] = useState(false)
  const observerRef = useRef<HTMLDivElement>(null)
 
  const loadMore = useCallback(async () => {
    if (!cursor || isLoading) return
 
    setIsLoading(true)
    const { items: newItems, nextCursor } = await fetchMore(cursor)
    setItems((prev) => [...prev, ...newItems])
    setCursor(nextCursor)
    setIsLoading(false)
  }, [cursor, isLoading, fetchMore])
 
  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          loadMore()
        }
      },
      { rootMargin: '200px' }, // Ładuj 200px przed końcem listy
    )
 
    if (observerRef.current) observer.observe(observerRef.current)
 
    return () => observer.disconnect()
  }, [loadMore])
 
  return (
    <div>
      {items.map((item) => (
        <div key={item.id}>{renderItem(item)}</div>
      ))}
 
      <div ref={observerRef} className="flex h-10 items-center justify-center">
        {isLoading && <span className="text-gray-400">Ładowanie...</span>}
        {!cursor && !isLoading && (
          <span className="text-gray-400">To już wszystko</span>
        )}
      </div>
    </div>
  )
}
Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.
Next.js

Często zadawane pytania

Czy Google indeksuje strony z ?page=2?

Tak, pod warunkiem że mają canonical wskazujący na siebie i są dostępne przez linkowanie wewnętrzne albo sitemapę. Google traktuje ?page=2 jak osobną stronę z unikalną treścią. Kluczowe jest, żeby canonical strony drugiej wskazywał na ?page=2, a nie na pierwszą stronę, ponieważ w przeciwnym razie głębsze strony mogą wypaść z indeksu.

Dla blogów i artykułów rozsądne jest 10–20 pozycji. Dla siatek produktów sprawdza się 20–48, najlepiej w wielokrotności liczby kolumn (2, 3, 4), żeby ostatni rząd był pełny. Najważniejsze to przetestować na prawdziwych danych: zbyt wiele elementów spowalnia ładowanie strony, zbyt mało zmusza użytkownika do nadmiaru kliknięć.

Offset (skip/OFFSET) mówi bazie, ile rekordów pominąć — jest prosty i pozwala skoczyć do dowolnej strony, ale przy dużych przesunięciach baza musi fizycznie przejść przez wszystkie pominięte wiersze, co go spowalnia. Cursor zamiast tego pamięta pozycję ostatniego elementu (np. jego ID albo datę) i pobiera rekordy „po" nim — jest szybki niezależnie od skali, ale działa sekwencyjnie, więc nie przeskoczysz nim od razu do strony pięćdziesiątej.

Może. Przy infinite scroll Google zwykle widzi tylko pierwszą porcję treści, ponieważ kolejne ładują się dopiero w reakcji na scroll, którego robot nie wykonuje tak jak użytkownik. Jeśli treść ma być indeksowana, użyj numerycznej paginacji z prawdziwymi adresami URL. Infinite scroll zostaw dla paneli użytkownika, feedów i widoków, które nie muszą trafiać do wyszukiwarki. Taki podział jest najbardziej optymalny i bezpieczny.

Nie. Google oficjalnie przestał używać rel="prev" i rel="next" jako sygnału do rozumienia serii paginowanych stron. Generowanie ich przez metadata.other tworzy zresztą meta tagi, a nie linki relacyjne, więc nie spełnia nawet pierwotnej funkcji. Skup się na tym, co faktycznie działa: stabilne URL-e, poprawny canonical na każdej stronie, brak duplikatów i czytelne linkowanie między stronami.

O autorze

Maciej Sala

Maciej Sala — Product Manager i Frontend Developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwija własne projekty side-projecty.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną

Biblioteka wiedzy

Czytaj dalej

Zobacz więcej wpisów