Przejdź do treści

Wyszukiwarka z filtrami w Next.js — searchParams, debounce, URL state i UX

Wyszukiwarka z filtrami w Next.js, której wynik można skopiować z URL-a — jak zbudować URL state z searchParams, debounce i dobrym UX?

Maciej Sala

Founder StriveLab

5 min czytaniaOpublikowano 11 kwietnia 2026 (Aktualizacja 11 czerwca 2026)

Dlaczego URL state w Next.js zamiast useState?

Filtry w giną po odświeżeniu strony. (?q=next&tag=react&tag=seo&sort=newest) przetrwa: odświeżenie, udostępnienie linku, nawigację wstecz/wprzód i jest czytelny dla Server Components bez klientowego JS do odczytania filtrów.

Architektura wyszukiwarki z filtrami w Next.js

Code
URL: /blog?q=next&tag=react&sort=newest
                    ↓
Server Component → czyta searchParams → pobiera dane z filtrami
                    ↓
Client Component → aktualizuje URL → Server Component re-renderuje

Server Component: odczyt searchParams i pobieranie danych

Code
// app/blog/page.tsx
import { Suspense } from 'react'
import { ActiveFilters } from '@/components/active-filters'
import { SearchBar } from '@/components/search-bar'
import { FilterSidebar } from '@/components/filter-sidebar'
import { PostGrid } from '@/components/post-grid'
import { PostGridSkeleton } from '@/components/skeletons'
 
type SortOption = 'newest' | 'oldest' | 'popular'
 
interface SearchParams {
  q?: string
  tag?: string | string[]
  sort?: string
  page?: string
}
 
interface NormalizedFilters {
  query: string
  tags: string[]
  sort: SortOption
  page: number
}
 
const SORT_OPTIONS = ['newest', 'oldest', 'popular'] as const
 
function normalizeSearchParams(params: SearchParams): NormalizedFilters {
  const tags = Array.isArray(params.tag)
    ? params.tag
    : params.tag
      ? [params.tag]
      : []
 
  const sort = SORT_OPTIONS.includes(params.sort as SortOption)
    ? (params.sort as SortOption)
    : 'newest'
 
  const page = Number.parseInt(params.page ?? '1', 10)
 
  return {
    query: (params.q ?? '').trim(),
    tags: tags.map((tag) => tag.trim()).filter(Boolean),
    sort,
    page: Number.isFinite(page) && page > 0 ? page : 1,
  }
}
 
export default async function BlogPage({
  searchParams,
}: {
  searchParams: Promise<SearchParams>
}) {
  const rawParams = await searchParams
  const filters = normalizeSearchParams(rawParams)
  const resultsKey = JSON.stringify(filters)
 
  return (
    <main className="mx-auto max-w-6xl py-8">
      <h1 className="mb-6 text-3xl font-bold">Blog</h1>
 
      <Suspense fallback={<div className="h-12" />}>
        <SearchBar defaultValue={filters.query} />
      </Suspense>
 
      <div className="mt-6 grid grid-cols-4 gap-8">
        <aside className="col-span-1">
          <Suspense fallback={null}>
            <FilterSidebar
              currentTags={filters.tags}
              currentSort={filters.sort}
            />
          </Suspense>
        </aside>
 
        <div className="col-span-3">
          <Suspense fallback={null}>
            <ActiveFilters />
          </Suspense>
 
          {/* Suspense resetuje fallback przy każdej zmianie znormalizowanych filtrów */}
          <Suspense key={resultsKey} fallback={<PostGridSkeleton />}>
            <FilteredPosts filters={filters} />
          </Suspense>
        </div>
      </div>
    </main>
  )
}
 
async function FilteredPosts({ filters }: { filters: NormalizedFilters }) {
  const posts = await getFilteredPosts(filters)
 
  if (posts.length === 0) {
    return (
      <p className="py-12 text-center text-gray-500">
        Brak wyników dla podanych filtrów.
      </p>
    )
  }
 
  return <PostGrid posts={posts} />
}

Prosty trik: key={resultsKey} na <Suspense> — zmiana filtrów = nowy key = nowy skeleton = nowe pobranie danych. Klucz buduj ze znormalizowanych filtrów, a nie z surowego obiektu searchParams, bo kolejność parametrów i puste wartości nie powinny tworzyć sztucznych stanów.

Drugi detal jest ważny przy buildzie produkcyjnym: komponenty klienckie, które używają useSearchParams, opakuj w <Suspense>. W trybie deweloperskim może działać bez tego, ale przy statycznie renderowanej stronie Next.js wymaga granicy Suspense albo przeniesie większą część drzewa w client-side rendering.

Walidacja parametrów w URL wyszukiwarki

Nie traktuj searchParams jak zaufanego stanu aplikacji. To część URL-a, więc użytkownik może wpisać tam cokolwiek: ?page=-100, ?sort=DROP_TABLE, ?tag= albo bardzo długie q.

Minimalny zestaw zasad:

  • trimuj tekst wyszukiwania i ustaw minimalną długość, np. 2 znaki,
  • whitelistuj sortowanie, zamiast przekazywać dowolny string do zapytania,
  • normalizuj multi-select, bo tag może być stringiem, tablicą albo pustą wartością,
  • pilnuj paginacji, czyli minimum 1 i rozsądny maksymalny perPage,
  • nie buduj router.push z niezweryfikowanego URL-a, tylko z pathname i URLSearchParams.

Dzięki temu URL pozostaje udostępnialny, ale nie staje się drugim, niekontrolowanym magazynem stanu aplikacji.

Pole wyszukiwania z debounce w Next.js

chroni serwer przed zapytaniem na każdy wpisany znak.

Code
// components/search-bar.tsx
'use client'
 
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
import { useEffect, useState, useTransition } from 'react'
 
export function SearchBar({ defaultValue }: { defaultValue?: string }) {
  const [query, setQuery] = useState(defaultValue || '')
  const [isPending, startTransition] = useTransition()
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()
  const queryFromUrl = searchParams.get('q') ?? ''
 
  useEffect(() => {
    setQuery(queryFromUrl)
  }, [queryFromUrl])
 
  // Debounce — aktualizuj URL po 300 ms od ostatniego naciśnięcia klawisza
  useEffect(() => {
    const timer = setTimeout(() => {
      const nextQuery = query.trim()
      if (nextQuery === queryFromUrl) return
 
      const params = new URLSearchParams(searchParams.toString())
 
      if (nextQuery) {
        params.set('q', nextQuery)
      } else {
        params.delete('q')
      }
      params.delete('page')
 
      const queryString = params.toString()
      const href = queryString ? `${pathname}?${queryString}` : pathname
 
      startTransition(() => {
        router.replace(href, { scroll: false })
      })
    }, 300)
 
    return () => clearTimeout(timer)
  }, [query, queryFromUrl, router, pathname, searchParams, startTransition])
 
  return (
    <div className="relative">
      <input
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Szukaj artykułów..."
        aria-label="Szukaj artykułów"
        className="w-full rounded-xl border px-5 py-3 pr-12"
      />
      {isPending && (
        <div className="absolute right-4 top-1/2 -translate-y-1/2">
          <span className="sr-only">Aktualizowanie wyników</span>
          <div className="h-5 w-5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
        </div>
      )}
    </div>
  )
}

useTransition oznacza aktualizację URL jako niski priorytet, więc wpisywanie w pole jest mniej podatne na lagi przy wolniejszej nawigacji. router.replace jest tu lepszy niż router.push, bo debounce może odpalić kilka zmian podczas jednego wpisywania — użytkownik nie chce potem klikać „wstecz” przez każdy pośredni stan. Nadal pilnuj kosztu zapytań i nie odpalaj ciężkiego wyszukiwania pełnotekstowego na każdy znak bez debounce.

router.replace czy router.push przy filtrach?

Praktyczna reguła jest prosta:

InterakcjaMetodaDlaczego
Wpisywanie w pole wyszukiwaniarouter.replace()Nie zaśmieca historii stanami pośrednimi
Kliknięcie filtrarouter.push()To świadoma zmiana widoku, do której można wrócić
Zmiana sortowaniarouter.push()Użytkownik często porównuje warianty
Paginacjarouter.push()Strony wyników powinny działać z przyciskiem „wstecz”
Zmiana czysto wizualna, bez pobrania listyHistory APINie musi ponownie renderować Server Component

Jeśli chcesz zmienić tylko lokalny stan URL-a i nie potrzebujesz nowego renderu Server Component, Next.js wspiera natywne window.history.pushState i window.history.replaceState, które współpracują z usePathname i useSearchParams. Do filtrowania wyników po stronie serwera zwykle jednak chcesz normalną nawigację przez router.

Panel filtrów w Next.js i stan w URL

Code
// components/filter-sidebar.tsx
'use client'
 
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
import { useCallback } from 'react'
 
const TAGS = [
  'React',
  'Next.js',
  'TypeScript',
  'SEO',
  'Tailwind CSS',
  'Testing',
]
const SORT_OPTIONS = [
  { value: 'newest', label: 'Najnowsze' },
  { value: 'oldest', label: 'Najstarsze' },
  { value: 'popular', label: 'Najpopularniejsze' },
]
 
export function FilterSidebar({
  currentTags,
  currentSort,
}: {
  currentTags: string[]
  currentSort?: string
}) {
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()
 
  const updateTags = useCallback(
    (tag: string) => {
      const params = new URLSearchParams(searchParams.toString())
 
      const nextTags = currentTags.includes(tag)
        ? currentTags.filter((currentTag) => currentTag !== tag)
        : [...currentTags, tag]
 
      params.delete('tag')
      nextTags.forEach((nextTag) => params.append('tag', nextTag))
      params.delete('page')
 
      const queryString = params.toString()
      router.push(queryString ? `${pathname}?${queryString}` : pathname, {
        scroll: false,
      })
    },
    [router, pathname, searchParams, currentTags],
  )
 
  const updateSort = useCallback(
    (value: string) => {
      const params = new URLSearchParams(searchParams.toString())
 
      if (value === 'newest') {
        params.delete('sort')
      } else {
        params.set('sort', value)
      }
      params.delete('page')
 
      const queryString = params.toString()
      router.push(queryString ? `${pathname}?${queryString}` : pathname, {
        scroll: false,
      })
    },
    [router, pathname, searchParams],
  )
 
  const clearFilters = useCallback(() => {
    const params = new URLSearchParams(searchParams.toString())
    params.delete('tag')
    params.delete('sort')
    params.delete('page')
 
    const queryString = params.toString()
    router.push(queryString ? `${pathname}?${queryString}` : pathname, {
      scroll: false,
    })
  }, [router, pathname, searchParams])
 
  const hasActiveFilters =
    currentTags.length > 0 || (currentSort && currentSort !== 'newest')
 
  const clearAll = useCallback(() => {
    router.push(pathname, { scroll: false })
  }, [router, pathname])
 
  return (
    <div className="space-y-6">
      {/* Tagi */}
      <div>
        <h3 className="mb-3 font-semibold">Kategorie</h3>
        <div className="space-y-1">
          {TAGS.map((tag) => (
            <button
              key={tag}
              type="button"
              aria-pressed={currentTags.includes(tag)}
              onClick={() => updateTags(tag)}
              className={`block w-full rounded-lg px-3 py-2 text-left text-sm ${
                currentTags.includes(tag)
                  ? 'bg-blue-100 font-medium text-blue-700'
                  : 'text-gray-600 hover:bg-gray-100'
              }`}
            >
              {tag}
            </button>
          ))}
        </div>
      </div>
 
      {/* Sortowanie */}
      <div>
        <h3 className="mb-3 font-semibold">Sortowanie</h3>
        <select
          value={currentSort || 'newest'}
          onChange={(e) => updateSort(e.target.value)}
          className="w-full rounded-lg border p-2 text-sm"
        >
          {SORT_OPTIONS.map((option) => (
            <option key={option.value} value={option.value}>
              {option.label}
            </option>
          ))}
        </select>
      </div>
 
      {/* Reset */}
      {hasActiveFilters && (
        <button
          type="button"
          onClick={clearFilters}
          className="text-sm text-red-500 hover:underline"
        >
          Wyczyść filtry
        </button>
      )}
 
      {searchParams.toString() && (
        <button
          type="button"
          onClick={clearAll}
          className="text-sm text-gray-500 hover:underline"
        >
          Wyczyść wszystko
        </button>
      )}
    </div>
  )
}

Aktywne filtry jako etykiety w UI

Code
// components/active-filters.tsx
'use client'
 
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
 
export function ActiveFilters() {
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()
  const query = searchParams.get('q')
  const sort = searchParams.get('sort')
  const tags = searchParams.getAll('tag')
 
  const activeFilters = [
    query ? { key: 'q', value: query, label: `"${query}"` } : null,
    ...tags.map((tag) => ({ key: 'tag', value: tag, label: tag })),
    sort && sort !== 'newest'
      ? { key: 'sort', value: sort, label: sort }
      : null,
  ].filter(Boolean) as Array<{ key: string; value: string; label: string }>
 
  if (activeFilters.length === 0) return null
 
  function removeFilter(key: string, value: string) {
    const params = new URLSearchParams(searchParams.toString())
 
    if (key === 'tag') {
      const nextTags = params.getAll('tag').filter((tag) => tag !== value)
      params.delete('tag')
      nextTags.forEach((tag) => params.append('tag', tag))
    } else {
      params.delete(key)
    }
 
    params.delete('page')
 
    const queryString = params.toString()
    router.push(queryString ? `${pathname}?${queryString}` : pathname, {
      scroll: false,
    })
  }
 
  return (
    <div className="mb-4 flex flex-wrap gap-2">
      {activeFilters.map(({ key, value, label }) => (
        <span
          key={`${key}:${value}`}
          className="flex items-center gap-1 rounded-full bg-blue-100 px-3 py-1 text-sm text-blue-700"
        >
          {label}
          <button
            type="button"
            aria-label={`Usuń filtr ${label}`}
            onClick={() => removeFilter(key, value)}
            className="ml-1 hover:text-blue-900"
          >

          </button>
        </span>
      ))}
    </div>
  )
}

UX i dostępność wyszukiwarki z filtrami

Wyszukiwarka z filtrami jest elementem interaktywnym, więc sam działający URL nie wystarczy. Zadbaj o kilka drobiazgów:

  • input[type="search"] powinien mieć aria-label, jeśli placeholder jest jedynym opisem,
  • przyciski filtrów powinny mieć aria-pressed, bo zachowują się jak przełączniki,
  • przycisk usuwania etykiety powinien mieć aria-label, np. Usuń filtr React,
  • spinner powinien mieć tekst dla czytników ekranu, np. sr-only,
  • nie kasuj scrolla przy każdej zmianie filtrów, jeśli użytkownik pracuje w środku listy,
  • pokazuj pusty stan z informacją, które filtry można usunąć.

Największy błąd to traktowanie filtrów jak formularza, który „magicznie” zmienia wyniki bez sygnału zwrotnego. Użytkownik powinien widzieć, że wyniki są aktualizowane, które filtry są aktywne i jak wrócić do szerszego zestawu.

Zapytanie do bazy danych z filtrami z URL

Code
// lib/queries.ts
import type { Prisma } from '@prisma/client'
 
type SortOption = 'newest' | 'oldest' | 'popular'
 
interface PostFilters {
  query?: string
  tags?: string[]
  sort?: SortOption
  page?: number
  perPage?: number
}
 
export async function getFilteredPosts(filters: PostFilters) {
  const { query, tags = [], sort = 'newest', page = 1, perPage = 12 } = filters
 
  const safePage = Math.max(1, page)
  const safePerPage = Math.min(Math.max(perPage, 1), 48)
  const normalizedQuery = query?.trim()
  const where: Prisma.PostWhereInput = { published: true }
 
  if (normalizedQuery && normalizedQuery.length >= 2) {
    where.OR = [
      { title: { contains: normalizedQuery, mode: 'insensitive' } },
      { content: { contains: normalizedQuery, mode: 'insensitive' } },
      { excerpt: { contains: normalizedQuery, mode: 'insensitive' } },
    ]
  }
 
  if (tags.length > 0) {
    where.tags = { hasEvery: tags }
  }
 
  const orderBy =
    sort === 'oldest'
      ? { createdAt: 'asc' as const }
      : sort === 'popular'
        ? { views: 'desc' as const }
        : { createdAt: 'desc' as const }
 
  return db.post.findMany({
    where,
    orderBy,
    skip: (safePage - 1) * safePerPage,
    take: safePerPage,
  })
}

SEO filtrowanych URL-i w Next.js

Filtry w URL są świetne dla użytkownika, ale mogą być kosztowne dla . Jeśli każdy parametr tworzy nowy indeksowalny adres, łatwo wygenerować tysiące podobnych stron:

Code
/blog?tag=react
/blog?tag=react&sort=popular
/blog?tag=react&tag=nextjs&sort=oldest
/blog?q=react&tag=nextjs&page=7

Nie każda kombinacja zasługuje na indeks. W praktyce podziel filtrowane URL-e na trzy grupy:

Typ URL-aDecyzja SEO
Główne kategorie i ważne tagiindeksuj, linkuj wewnętrznie, daj własny title
Sortowanie, wyszukiwanie tekstowe, paginacjazwykle link kanoniczny do głównej wersji albo noindex
Losowe kombinacje wielu filtrów o małym popycieogranicz crawling lub linkowanie

canonical jest dobrym sygnałem, ale nie jest magicznym przełącznikiem crawl budgetu. Przy dużych katalogach rozważ też noindex, ograniczenie linków do kombinacji filtrów i reguły w robots.txt dla parametrów, których nie chcesz crawlowanych. Google w dokumentacji faceted navigation zwraca uwagę, że parametry filtrów mogą tworzyć bardzo dużą przestrzeń URL-i i spowalniać odkrywanie ważniejszych stron.

Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.
Next.js

Często zadawane pytania

Czy zmiana filtrów powoduje pełne przeładowanie strony?

Nie — router.push albo router.replace z { scroll: false } wykonuje miękką nawigację po stronie klienta. Server Component renderuje nową listę wyników, ale wspólny layout, nawigacja i panel filtrów nie przeładowują całego dokumentu.

Jak obsłużyć wiele tagów naraz?

Użyj powtarzalnego parametru w URL: ?tag=react&tag=nextjs. W Client Component useSearchParams().getAll("tag") zwraca tablicę, a w Server Component searchParams może mieć tag jako string albo string[] — obsłuż oba przypadki w funkcji normalizującej.

Czy Google indeksuje strony z filtrami?

Może, ale nie powinien indeksować każdej kombinacji. Dla wartościowych filtrów przygotuj stabilne, linkowane URL-e i link kanoniczny do właściwej wersji. Dla kombinacji technicznych albo niskiej jakości rozważ noindex, ograniczenie linkowania albo blokady w robots.txt, żeby nie marnować budżetu crawlowania.

Po co debounce, skoro mam useTransition?

To dwie różne warstwy. Debounce ogranicza, jak często aktualizujesz URL (np. raz na 300 ms zamiast na każdy znak) — chroni serwer przed lawiną zapytań. useTransition oznacza tę aktualizację jako niski priorytet, żeby wpisywanie w input pozostało płynne. Najlepiej działają razem.

Kiedy użyć router.replace, a kiedy router.push?

Dla wpisywania w pole wyszukiwania zwykle użyj router.replace, żeby nie zaśmiecać historii przeglądarki. Dla świadomych akcji, takich jak kliknięcie filtra, sortowanie albo paginacja, router.push ma sens, bo użytkownik może chcieć wrócić do poprzedniego widoku.

Dlaczego Suspense nie pokazuje skeletonu przy zmianie filtrów?

Bo bez zmiany key React traktuje to jako ten sam boundary i nie resetuje fallbacku. Dodaj stabilny key zależny od znormalizowanych filtrów. Pamiętaj też, że Client Components korzystające z useSearchParams powinny być opakowane w Suspense, jeśli strona ma zachować statyczną powłokę.

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 rozwijam własne projekty.

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

Skontaktuj się ze mną

Biblioteka wiedzy na temat Next.js

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

Jak React Server Components wpływają na SEO i performance?

Przez lata React pchał, coraz więcej i więcej pracy do przeglądarki, w efekcie czego mamy większe bundle, hydratacja trwa dłużej, są puste loadingi i strony, które bez JavaScriptu straciły sens. App Router odwraca ten kierunek. React Server Components pozwalają renderować treść na serwerze bez wysyłania całej logiki do klienta, a Server Actions upraszczają formularze i mutacje. To ma znaczenie dla SEO, bo crawler szybciej dostaje HTML. Ma też znaczenie dla biznesu, bo użytkownik szybciej widzi i dostaje to, po co przyszedł.

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