Klasyczna paginacja z numerami stron, w której 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 i stabilny URL. Jest on wygodny do udostępniania, indeksacji i taki, do którego wrócisz zakładką. Domyślny wybór dla blogów, katalogów i portfolio; właśnie z takiego rozwiązania korzystam na strivelab.pl.
2. Infinite scroll: ładuj więcej przy scrollu
Lista rośnie automatycznie, gdy użytkownik scrolluje. Takie rozwiązanie jest bardzo popularne w serwisach social media i feedach, może jest wygodne, ale znacznie 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, czyli użytkownik kontroluje ładowanie. Jest to dobry kompromis między UX a SEO.
URL-based paginacja rekomendowana dla SEO
Server Component z searchParams
Code
// app/blog/page.tsximport Link from 'next/link'import { notFound, permanentRedirect } from 'next/navigation'const POSTS_PER_PAGE = 10type SearchParamValue = string | string[] | undefinedfunction parsePage(rawPage: SearchParamValue) { if (!rawPage) return 1 if (Array.isArray(rawPage)) return null if (!/^[1-9]\d*$/.test(rawPage)) return null const page = Number(rawPage) return Number.isSafeInteger(page) ? page : null}export default async function BlogPage({ searchParams,}: { searchParams: Promise<{ page?: SearchParamValue }>}) { const params = await searchParams const currentPage = parsePage(params.page) if (currentPage === null) { notFound() } if (currentPage === 1 && params.page) { permanentRedirect('/blog') // 308 — trwały duplikat, nie tymczasowy stan } const { posts, totalPosts } = await getPaginatedPosts({ page: currentPage, perPage: POSTS_PER_PAGE, }) const totalPages = Math.ceil(totalPosts / POSTS_PER_PAGE) // Math.max: przy pustej liście (totalPages = 0) strona 1 może być pustym // stanem, ale ?page=2 wciąż powinno zwrócić 404, nie 200. if (currentPage > Math.max(totalPages, 1)) { notFound() } 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> )}
searchParams jest częścią URL-a, więc traktuj go jak input od użytkownika. Number(params.page) || 1 wygląda wygodnie, ale przepuszcza zbyt dużo niejednoznacznych przypadków: ?page=abc zmienia się w stronę pierwszą, ?page=1.5 zostaje liczbą, ?page=01 tworzy duplikat tej samej strony, a powtórzony parametr może przyjść jako tablica. Dla SEO i cache lepiej mieć jeden kanoniczny adres na jeden stan listy.
W Next.js 15 searchParams jest Promise, a jego wartości nie są znane w czasie budowania strony. Jest to z pewnością wygodne przy listach zależnych od parametrów zapytania, ale oznacza też, że taka strona działa w modelu zależnym od żądania. Jeśli archiwum ma być w pełni statyczne, rozważ segment ścieżki, np. /blog/page/2, zamiast parametru ?page=2.
Google nie używa już rel="prev" / rel="next" jako sygnału indeksowania serii, więc dla samego Google nie są potrzebne. Najważniejsze są stabilne URL-e, poprawny adres kanoniczny, linkowanie wewnętrzne i brak duplikatów. Czytają je jednak nadal inne wyszukiwarki i crawlery audytowe, więc jeśli chcesz dalej je emitować, Next.js ma do tego dedykowane pole pagination w Metadata API, które generuje prawdziwe elementy <link rel="prev"> i <link rel="next">. Jeśli je masz, to nie usuwaj, ale rozważyłbym, czy mają sens w nowym projekcie:
Nie generuj ich natomiast przez metadata.other, ponieważ to pole tworzy meta tagi (<meta name="prev">), a nie linki relacyjne. W związku z tym, nie spełnia pierwotnej funkcji.
Edge case'y SEO dla parametru page
Największe błędy w paginacji nie wynikają z samego ?page=2, tylko z dziesiątek wariantów tego samego stanu i każdy z nich może zjeść crawl budget, wpłynąć negatywnie na sygnały kanoniczne albo pokazać robotowi pustą stronę.
URL
Rekomendacja
Dlaczego
/blog
poprawny adres strony pierwszej
najczystszy adres kanoniczny dla początku listy
/blog?page=1
permanentRedirect('/blog')
duplikat strony pierwszej
/blog?page=2
200 + canonical na /blog?page=2
unikalna druga porcja treści
/blog?page=0
notFound() albo redirect do /blog
parametr spoza zakresu
/blog?page=abc
notFound()
niepoprawny input, nie stan listy
/blog?page=01
redirect do /blog albo notFound()
techniczny duplikat strony pierwszej
/blog?page=2&page=3
notFound()
niejednoznaczny stan URL-a
/blog?page=9999
notFound()
pusta strona poza zakresem serii
Jeśli paginujesz publiczne archiwum bloga, dodaj strony paginacji do sitemap tylko wtedy, gdy naprawdę chcesz je indeksować. Przy dużych katalogach produktowych zwykle lepiej generować sitemapę dla kategorii i najważniejszych filtrów, a bardzo głębokie strony paginacji zostawić do odkrywania przez linkowanie wewnętrzne.
?page=2 czy /page/2?
Query string nie jest błędem SEO sam w sobie, ponieważ ?page=2 może być poprawnym, indeksowalnym adresem. Pod warunkiem że ma własny adres kanoniczny, stabilną treść i linki HTML prowadzące do kolejnych stron. Kiedy jest źle? Gdy ta sama porcja treści istnieje pod kilkoma wariantami URL (duplikacja treści) albo gdy kolejne porcje są dostępne wyłącznie przez JavaScript (zbyt późne wczytywanie treści i problemy z widocznością w Google).
Wybór zależy od architektury:
Wzorzec
Kiedy używać
Uwaga
/blog?page=2
sortowanie, filtrowanie, listy zależne od query stringa
w App Router searchParams jest API zależnym od żądania
/blog/page/2
statyczne archiwa, blogi, kategorie, strony tagów
łatwiejsze do generowania, monitorowania i dodania do sitemap
Dla bloga, który ma statyczne archiwum i przewidywalną liczbę stron, /blog/page/2 bywa czystsze operacyjnie. Dla wyszukiwarki nie jest jednak ważny sam zapis adresu, tylko to, czy każda strona serii jest osiągalna przez zwykły link, odpowiada statusem 200, ma unikalną porcję treści i nie kanonizuje wszystkiego do strony pierwszej.
Problem wymaga od bazy danych przeskoczenia 10 000 rekordów, co przy milionach wierszy robi się wolno, nawet jeśli finalnie zwracasz tylko 10 pozycji. Dodatkowy koszt to COUNT(*), bez którego nie pokażesz liczby stron.
Offset nadal ma sens przy blogu, katalogu usług albo panelu administracyjnym, gdzie użytkownik naprawdę potrzebuje numerów stron. Zadbaj tylko o indeks pasujący do zapytania, np. published + createdAt + id.
Druga sprawa to kwestia stabilnego sortowania, bo jeśli dwie pozycje mają ten sam createdAt, baza może zwrócić je w różnej kolejności między żądaniami. W paginacji kończy się to duplikatem na stronie 2 albo brakującym elementem. Dlatego nawet przy offset pagination warto dodać tie-breaker, najczęściej będzie to id.
Przy bardzo ruchliwych listach pamiętaj też, że count() i findMany() mogą widzieć trochę inny stan danych, jeśli między zapytaniami pojawi się nowy rekord. W blogu to zwykle akceptowalne. W systemach transakcyjnych albo raportowych lepiej użyć spójnego snapshotu, transakcji albo cursor pagination.
nie pozwala przeskakiwać
do strony 50, bo wymaga sekwencyjnego przechodzenia. Takie rozwiązanie jest
idealne dla infinite scrolla, ale gorsze dla numerycznej paginacji.
Wersja z samym cursor: { id } jest kusząca, ale łatwo ją źle połączyć z sortowaniem po createdAt. Cursor musi odpowiadać kolejności sortowania. Jeśli sortujesz po dacie, dodaj stabilny tie-breaker, najczęściej id, bo dwa rekordy mogą mieć ten sam timestamp.
Kiedy co?
Kryterium
Offset
Cursor
Przeskakiwanie do strony N
Tak
Nie
Wydajność przy dużych zbiorach
Wolne
Szybkie
„Ile jest stron?"
Łatwe (COUNT)
Trudne
Infinite scroll
Możliwe
Idealne
SEO (numeryczne URL)
Natywne
Wymaga obejścia
Rekomendowałbym 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 nie jest ważny numer strony, ale stabilność i wydajność.
Endpoint do doładowywania kursorem
Komponent klientowy nie powinien znać ORM-a. Wystaw cienki Route Handler, który waliduje cursor, pobiera następną porcję i zwraca tylko dane potrzebne do renderu.
Code
// app/api/posts/route.tsimport { NextRequest, NextResponse } from 'next/server'const PAGE_SIZE = 20export async function GET(request: NextRequest) { const cursor = request.nextUrl.searchParams.get('cursor') ?? undefined // Zły input to 400, awaria po naszej stronie to 500 — nie sklejaj ich // w jeden status, bo klient nie odróżni "popraw żądanie" od "spróbuj później". if (cursor) { try { decodeCursor(cursor) } catch { return NextResponse.json( { error: 'Nieprawidłowy cursor' }, { status: 400 }, ) } } try { const { posts, nextCursor, hasMore } = await getPaginatedPosts({ cursor, perPage: PAGE_SIZE, }) return NextResponse.json({ items: posts.map((post) => ({ id: post.id, title: post.title, excerpt: post.excerpt, })), nextCursor, hasMore, }) } catch { return NextResponse.json( { error: 'Nie udało się pobrać kolejnej strony' }, { status: 500 }, ) }}
Jeśli cursor jest podpisany albo szyfrowany, możesz odrzucać zmanipulowane wartości bez zdradzania struktury bazy. Przy publicznym feedzie zwykle wystarczy bezpieczne kodowanie i defensywny try/catch, ale przy danych wrażliwych cursor nie powinien ujawniać identyfikatorów ani timestampów bez kontroli.
Infinite scroll z Intersection Observer
Infinite scroll najczęściej zbudujesz na . Na końcu listy ustawiasz element wartownik, a jego pojawienie się w widoku uruchamia doładowanie kolejnej porcji.
Automatyczny scroll jest wygodny, ale trudniej wrócić do konkretnej pozycji, trudniej pokazać użytkownikowi, gdzie jest w serii, i łatwiej odpalić kilka requestów naraz. Dlatego w panelach i feedach dodaj deduplikację po id, obsługę błędów oraz aria-live, a w publicznych listach treści rozważ dedykowany przycisk.
Load more jako bezpieczny kompromis
Przycisk „Załaduj więcej" jest mniej efektowny niż automatyczny infinite scroll, ale daje użytkownikowi kontrolę i prostszą dostępność. Ten sam Route Handler z kursorem działa bez zmian:
Jeśli treść ma być indeksowana, przycisk nie zastępuje prawdziwych URL-i. Najlepsza hybryda to klasyczne linki /blog?page=2, /blog?page=3 albo /blog/page/2 dla robotów i użytkowników bez JavaScriptu, a ulepszenie klientowe dopiero na wierzchu. Wtedy SEO widzi serię stron, a użytkownik może dostać płynniejsze doładowywanie.
W wersji dopracowanej infinite scroll powinien aktualizować adres przez History API, gdy użytkownik przechodzi do kolejnej porcji. Nie chodzi o sztuczne upychanie wszystkiego pod jednym URL-em, tylko o to, żeby po odświeżeniu, udostępnieniu albo wejściu z wyszukiwarki użytkownik dostał tę samą porcję listy.
Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.
Tak, pod warunkiem że mają kanoniczny adres 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ą. Kanoniczny URL drugiej strony powinien wskazywać na ?page=2, a nie na pierwszą stronę, ponieważ w przeciwnym razie głębsze strony mogą wypaść z indeksu.
Ile elementów powinno być na stronę?
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. Sprawdź to na prawdziwych danych: zbyt wiele elementów spowalnia ładowanie strony, zbyt mało zmusza użytkownika do nadmiaru kliknięć.
Czym różni się offset od cursor pagination?
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, zwykle ID albo parę createdAt + id, i pobiera rekordy po nim. Jest szybki przy dużej skali, ale działa sekwencyjnie, więc nie przeskoczysz nim od razu do strony pięćdziesiątej.
Czy infinite scroll szkodzi SEO?
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 bezpieczny i przewidywalny.
Czy nadal trzeba dodawać rel=prev i rel=next?
Nie trzeba — Google oficjalnie przestał używać rel="prev" i rel="next" jako sygnału do rozumienia serii paginowanych stron. Nadal czytają je jednak inne wyszukiwarki i narzędzia audytowe, więc jeśli chcesz je mieć, użyj pola pagination w Metadata API Next.js, które generuje prawdziwe elementy link. Nie generuj ich przez metadata.other, bo to pole tworzy meta tagi, a nie linki relacyjne. Dla Google liczą się przede wszystkim stabilne URL-e, poprawny canonical na każdej stronie, brak duplikatów i czytelne linkowanie między stronami.
Co zrobić z błędnymi parametrami page?
Znormalizuj pierwszą stronę do czystego URL-a, np. /blog zamiast /blog?page=1, a strony poza zakresem zwracaj przez notFound(). Parametry typu ?page=abc, ?page=-1, ?page=0, ?page=1.5 albo ?page=01 nie powinny tworzyć indeksowalnych wariantów tej samej listy. Najbezpieczniej przyjąć tylko dodatnie liczby całkowite bez zer wiodących, a resztę przekierować albo odrzucić.
Czy lepsze jest `/blog?page=2` czy `/blog/page/2`?
Oba wzorce mogą być poprawne, jeśli mają stabilną treść, własny canonical i linki HTML do kolejnych stron. W Next.js App Router searchParams jest wygodne dla sortowania, filtrów i szybkich list, ale jest API zależnym od żądania. Dla statycznych blogów i archiwów często praktyczniejszy jest segment ścieżki typu /blog/page/2, bo łatwiej go wygenerować, dodać do sitemap i monitorować jako osobną stronę.
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.
Parametry w URL mają, jak przysłowiowa moneta ma dwie strony. Z jednej, napędzają filtry, sortowanie, paginację i śledzenie kampanii, a z drugiej, jednocześnie potrafią cicho popsuć widoczność serwisu przez duplikacje treści, marnowany budżet indeksowania i rozwodnione link equity. W aplikacjach React, Next.js i Astro to problem architektoniczny i właśnie dlatego samo użycie rel="canonical" go nie rozwiązuje. W tym przewodniku przechodzę od klasyfikacji parametrów do decyzji o indeksacji, normalizacji URL-i, renderowania, paginacji oraz kontroli nawigacji fasetowej.
Maciej Sala
Founder StriveLab
Frontend rzadko kończy się na komponencie i jednym fetch , a im bliżej realnego produktu, tym częściej okazuje się, że jakość UI zależy od tego, co dzieje się po drugiej stronie API . Jak backend paginuje dane, jak zwraca błędy, jak trzyma sesję, co robi przy timeoutach i czy potrafi przyjąć większy ruch.
Maciej Sala
Founder StriveLab
Trudno wyobrazić sobie współczesną analitykę bez Google Search Console GSC , czyli podstawowego narzędzia pokazującego dane bezpośrednio z Google Search. PageSpeed Insights mierzy wydajność, Ahrefs śledzi backlinki, ale GSC pokazuje całą paletę danych ale też problemów/błędów dotyczących strony internetowej. Przykładowo, które strony są zaindeksowane, jakie błędy crawlowania występują, na jakie frazy rankujesz i jakie wyniki Core Web Vitals Dane Core Web Vitals w GSC pochodzą z Chrome UX Report, czyli realnych wizyt użytkowników, a nie z pojedynczego testu laboratoryjnego Lighthouse. mają istniejący użytkownicy.