Paginacja w Next.js: Offset vs Cursor i Infinite Scroll

Opublikowano
11 kwietnia 2026
Aktualizacja
2 lipca 2026
Czas czytania
8 min czytania

Trzy rodzaje paginacji

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

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.tsx
import Link from 'next/link'
import { notFound, permanentRedirect } from 'next/navigation'
 
const POSTS_PER_PAGE = 10
 
type SearchParamValue = string | string[] | undefined
 
function 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.

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: kanoniczny adres dla paginacji

Code
// app/blog/page.tsx
import type { Metadata } from 'next'
 
export async function generateMetadata({
  searchParams,
}: {
  searchParams: Promise<{ page?: SearchParamValue }>
}): Promise<Metadata> {
  const params = await searchParams
  const page = parsePage(params.page) ?? 1
  const baseUrl = 'https://example.com/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 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:

Code
return {
  title: `Blog, strona ${page} | StriveLab`,
  alternates: { canonical: `${baseUrl}?page=${page}` },
  pagination: {
    previous: page === 2 ? baseUrl : `${baseUrl}?page=${page - 1}`,
    next: page < totalPages ? `${baseUrl}?page=${page + 1}` : undefined,
  },
}

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ę.

URLRekomendacjaDlaczego
/blogpoprawny adres strony pierwszejnajczystszy adres kanoniczny dla początku listy
/blog?page=1permanentRedirect('/blog')duplikat strony pierwszej
/blog?page=2200 + canonical na /blog?page=2unikalna druga porcja treści
/blog?page=0notFound() albo redirect do /blogparametr spoza zakresu
/blog?page=abcnotFound()niepoprawny input, nie stan listy
/blog?page=01redirect do /blog albo notFound()techniczny duplikat strony pierwszej
/blog?page=2&page=3notFound()niejednoznaczny stan URL-a
/blog?page=9999notFound()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:

WzorzecKiedy używaćUwaga
/blog?page=2sortowanie, filtrowanie, listy zależne od query stringaw App Router searchParams jest API zależnym od żądania
/blog/page/2statyczne 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.

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' }, { id: 'desc' }],
      skip: offset,
      take: perPage,
    }),
    db.post.count({ where: { published: true } }),
  ])
 
  return { posts, totalPosts }
}

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.

Cursor pagination

Code
// Szybkie nawet przy milionach rekordów
type Cursor = {
  createdAt: string
  id: string
}
 
function encodeCursor(cursor: Cursor) {
  return Buffer.from(JSON.stringify(cursor)).toString('base64url')
}
 
function decodeCursor(cursor: string): Cursor {
  return JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'))
}
 
async function getPaginatedPosts({
  cursor,
  perPage,
}: {
  cursor?: string
  perPage: number
}) {
  const decodedCursor = cursor ? decodeCursor(cursor) : null
 
  const posts = await db.post.findMany({
    where: {
      published: true,
      ...(decodedCursor && {
        OR: [
          { createdAt: { lt: new Date(decodedCursor.createdAt) } },
          {
            createdAt: new Date(decodedCursor.createdAt),
            id: { lt: decodedCursor.id },
          },
        ],
      }),
    },
    orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
    take: perPage + 1,
  })
 
  const hasMore = posts.length > perPage
  const items = hasMore ? posts.slice(0, -1) : posts
  const lastItem = items.at(-1)
  const nextCursor =
    hasMore && lastItem
      ? encodeCursor({
          createdAt: lastItem.createdAt.toISOString(),
          id: lastItem.id,
        })
      : null
 
  return { posts: items, nextCursor, hasMore }
}

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?

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

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.ts
import { NextRequest, NextResponse } from 'next/server'
 
const PAGE_SIZE = 20
 
export 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.

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; hasMore: boolean }>
  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 [error, setError] = useState<string | null>(null)
  const [hasMore, setHasMore] = useState(Boolean(initialCursor))
  const observerRef = useRef<HTMLDivElement>(null)
 
  const loadMore = useCallback(async () => {
    if (!cursor || isLoading || !hasMore) return
 
    setIsLoading(true)
    setError(null)
 
    try {
      const { items: newItems, nextCursor, hasMore } = await fetchMore(cursor)
 
      setItems((prev) => {
        const existingIds = new Set(prev.map((item) => item.id))
        const uniqueItems = newItems.filter((item) => !existingIds.has(item.id))
        return [...prev, ...uniqueItems]
      })
      setCursor(nextCursor)
      setHasMore(hasMore)
    } catch {
      setError('Nie udało się załadować kolejnej strony.')
    } finally {
      setIsLoading(false)
    }
  }, [cursor, isLoading, hasMore, fetchMore])
 
  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasMore) {
          loadMore()
        }
      },
      { rootMargin: '200px' }, // Ładuj 200px przed końcem listy
    )
 
    if (observerRef.current) observer.observe(observerRef.current)
 
    return () => observer.disconnect()
  }, [loadMore, hasMore])
 
  return (
    <div>
      {items.map((item) => (
        <div key={item.id}>{renderItem(item)}</div>
      ))}
 
      <div
        ref={observerRef}
        aria-live="polite"
        className="flex h-10 items-center justify-center"
      >
        {isLoading && <span className="text-gray-400">Ładowanie...</span>}
        {error && (
          <button type="button" onClick={loadMore}>
            Spróbuj ponownie
          </button>
        )}
        {!hasMore && !isLoading && (
          <span className="text-gray-400">To już wszystko</span>
        )}
      </div>
    </div>
  )
}

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:

Code
'use client'
 
import { useState, useTransition } from 'react'
 
export function LoadMoreList<T extends { id: string }>({
  initialItems,
  initialCursor,
  fetchMore,
  renderItem,
}: InfiniteListProps<T>) {
  const [items, setItems] = useState(initialItems)
  const [cursor, setCursor] = useState(initialCursor)
  const [hasMore, setHasMore] = useState(Boolean(initialCursor))
  const [error, setError] = useState<string | null>(null)
  const [isPending, startTransition] = useTransition()
 
  function handleLoadMore() {
    if (!cursor || isPending) return
 
    startTransition(async () => {
      try {
        const result = await fetchMore(cursor)
        setItems((prev) => [...prev, ...result.items])
        setCursor(result.nextCursor)
        setHasMore(result.hasMore)
        setError(null)
      } catch {
        setError('Nie udało się załadować kolejnej strony.')
      }
    })
  }
 
  return (
    <section>
      {items.map((item) => (
        <div key={item.id}>{renderItem(item)}</div>
      ))}
 
      {error && <p role="alert">{error}</p>}
 
      {hasMore && (
        <button type="button" onClick={handleLoadMore} disabled={isPending}>
          {isPending
            ? 'Ładowanie...'
            : error
              ? 'Spróbuj ponownie'
              : 'Załaduj więcej'}
        </button>
      )}
    </section>
  )
}

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.
Next.js

Często zadawane pytania

Czy Google indeksuje strony z ?page=2?

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.

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ęć.

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.

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.

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.

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ć.

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.

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
Parametry w URL a SEO: jak nie zduplikować treści w React, Next.js i Astro?

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

Maciej Sala

Founder StriveLab

Backend dla frontendowca: serwer, bazy danych i API

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

Maciej Sala

Founder StriveLab

Google Search Console + Next.js — indeksacja, błędy, performance i co z nimi robić

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.

Maciej Sala

Maciej Sala

Founder StriveLab