StriveLab
Strony internetowe
Usługi
RealizacjeO mnieBlogPorozmawiajmy
PL
EN

Astro

Ultraszybkie projekty, łączące lekkość ze skalowalnością.

Next.js

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

React

Połączenie intuicyjności z wydajnością, które zapewnia bezproblemową skalowalność kodu.

SEO & Performance

Audyt techniczny i optymalizacja pod kątem SEO i GEO.

Automatyzacja AI

Bezpieczne automatyzacje procesów i agenci AI w n8n, Make i Claude.

QA & Automation

Testy automatyczne komponentów i E2E w Cypress.

Konsultacje

Połączenie perspektywy produktu, developera i marketingu w jednym miejscu

StriveLab
Strony internetowe
Usługi
RealizacjeO mnieBlogPorozmawiajmy
PL
EN

Astro

Ultraszybkie projekty, łączące lekkość ze skalowalnością.

Next.js

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

React

Połączenie intuicyjności z wydajnością, które zapewnia bezproblemową skalowalność kodu.

SEO & Performance

Audyt techniczny i optymalizacja pod kątem SEO i GEO.

Automatyzacja AI

Bezpieczne automatyzacje procesów i agenci AI w n8n, Make i Claude.

QA & Automation

Testy automatyczne komponentów i E2E w Cypress.

Konsultacje

Połączenie perspektywy produktu, developera i marketingu w jednym miejscu

Astro

Ultraszybkie projekty, łączące lekkość ze skalowalnością.

Next.js

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

React

Połączenie intuicyjności z wydajnością, które zapewnia bezproblemową skalowalność kodu.

SEO & Performance

Audyt techniczny i optymalizacja pod kątem SEO i GEO.

Automatyzacja AI

Bezpieczne automatyzacje procesów i agenci AI w n8n, Make i Claude.

QA & Automation

Testy automatyczne komponentów i E2E w Cypress.

Konsultacje

Połączenie perspektywy produktu, developera i marketingu w jednym miejscu

RealizacjeO mnieBlog
Porozmawiajmy
PL
EN

Nowoczesne strony internetowe dla firm, które myślą odważnie.

Przewiń do góry

Nazwa

StriveLab Maciej Sala

NIP

6772218995

REGON

524008527

E-mail

contact@strivelab.pl

Usługi główne
  • Tworzenie stron internetowych
  • Strony internetowe Next.js
  • Strony internetowe Astro
  • Strony internetowe React
Inne usługi
  • Usługi
  • Audyt SEO i Performance
  • Testy automatyczne i QA
  • Konsultacje Produktowe
  • Automatyzacja Procesów AI
  • Aplikacje webowe Next.js
  • Współpraca ciągła
Strony
  • O mnie
  • Usługi
  • Realizacje
  • Blog

© 2026 StriveLab.pl

Polityka prywatności
Next.jsReactUX

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?

OpublikujLinkedInFacebookWyślij
Autor
Maciej Sala
Opublikowano
11 kwietnia 2026 09:10
Czytanie
5 min czytania
Aktualizacja
11 czerwca 2026 12:00

Użytkownik ustawia trzy filtry, znajduje idealny wynik, wysyła link koledze — a tamten widzi pustą wyszukiwarkę. Bo filtry siedziały w useState i zginęły poza jego przeglądarką. URL state rozwiązuje to u źródła: cały stan filtrów żyje w adresie, więc przetrwa odświeżenie, udostępnienie i przycisk „wstecz”, a do tego jest czytelny dla Server Components. Pokazuję, jak zbudować taką wyszukiwarkę — z debounce, skeletonami, walidacją parametrów i SEO.

Dlaczego URL state zamiast useState?

Filtry w useState to hook React do trzymania stanu komponentu w pamięci — stan ginie przy odświeżeniu strony i nie da się go udostępnić linkiem. giną po odświeżeniu strony. URL state to wzorzec trzymania stanu aplikacji (np. filtrów) w parametrach adresu URL — przetrwa odświeżenie, udostępnienie linku i nawigację wstecz/wprzód. (?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.

Artykuł w skrócie

  • Stan filtrów trzymaj w URL, nie w useState — przetrwa odświeżenie, udostępnienie linku i nawigację wstecz; Server Component czyta go bez klientowego JS.
  • Normalizuj searchParams po stronie serwera — tag może być stringiem albo tablicą, page może być błędny, a sort powinien mieć whitelistę.
  • Debounce + router.replace + useTransition dla pola wyszukiwania — mniej zapytań, mniej wpisów w historii, pole bez lagów.
  • Client Components z useSearchParams opakuj w Suspense — zachowasz statyczną powłokę i unikniesz niepotrzebnego CSR bailout.
  • SEO filtrów to decyzja, nie jeden canonical — indeksuj tylko wartościowe kombinacje, resztę ograniczaj przez canonical, noindex, linkowanie lub robots.txt.

Architektura

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 filtrów 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

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

Debounce to technika opóźniania reakcji do momentu, aż użytkownik przestanie działać — np. aktualizacja po 300 ms od ostatniego wciśniętego znaku, zamiast po każdym. 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.

replace czy push?

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 z filtrami

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 — etykiety

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

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 UX 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 z filtrami

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

Filtry w URL są świetne dla użytkownika, ale mogą być kosztowne dla SEO. 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 canonical 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.

Werdykt Labu

Wyszukiwarka z filtrami w Next.js App Router stoi na jednej decyzji: stan filtrów żyje w URL, nie w useState. To daje za darmo udostępnialność, przetrwanie odświeżenia, działający przycisk „wstecz” i odczyt po stronie serwera bez klientowego JS. Reszta to mechanika: Server Component normalizuje searchParams i pobiera dane, Client Component aktualizuje URL, debounce z router.replace i useTransition trzyma input płynnym, a <Suspense key> pokazuje skeleton przy każdej zmianie wyników.

Dwa wzorce pokrywają 90% interakcji: updateTags albo updateSort do świadomych zmian oraz replace dla wpisywania tekstu. A pod kątem SEO — nie indeksuj wszystkiego. Najpierw zdecyduj, które kombinacje filtrów mają realny popyt i własną wartość, a dopiero potem dobieraj canonical, noindex albo blokowanie crawlowania.

Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.
Next.js
  • Dlaczego URL state zamiast useState?1 min
  • Architektura1 min
  • Server Component — odczyt filtrów i pobieranie danych1 min
  • Walidacja parametrów w URL1 min
  • Pole wyszukiwania z debounce1 min
  • replace czy push?1 min
  • Panel z filtrami1 min
  • Aktywne filtry — etykiety1 min
  • UX i dostępność1 min
  • Zapytanie do bazy z filtrami1 min
  • SEO filtrowanych URL-i1 min
  • Werdykt Labu1 min

Często zadawane pytania

Źródła i dokumentacjaZweryfikowano: 11 czerwca 2026

API searchParams, useSearchParams, nawigację i SEO filtrów zweryfikowano na podstawie oficjalnej dokumentacji Next.js, MDN i Google:

Next.js docs: searchParams, Next.js docs: useSearchParams, Next.js docs: useRouter, Next.js docs: missing Suspense boundary with useSearchParams, Next.js docs: linking and navigating, MDN: URLSearchParams, Google Search Central: managing faceted navigation.

Maciej Sala

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.

Moje artykułyWięcej o mnie

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
Google Search Console + Next.js — indeksacja, błędy, performance i co z nimi robić
Google Search Console + Next.js — indeksacja, błędy, performance i co z nimi robić

GSC dla strony Next.js — jak czytać dane o indeksacji, rozumieć błędy crawlowania i reagować na spadki, zanim ruch organiczny zniknie.

Maciej Sala

Maciej Sala

Founder Strivelab

11 kwietnia 2026
Next.js a SEO — kiedy naprawdę daje przewagę nad zwykłym Reactem
Next.js a SEO — kiedy naprawdę daje przewagę nad zwykłym Reactem

Next.js naprawdę poprawia SEO. Kiedy SSR i SSG dają realną przewagę nad zwykłym Reactem i kiedy ta różnica jest pozorna?

Maciej Sala

Maciej Sala

Founder Strivelab

15 lipca 2025
Migracja z WordPress do Next.js — redirecty 301 i pozycje SEO
Migracja z WordPress do Next.js — redirecty 301 i pozycje SEO

Migracja z WordPress do Next.js bez utraty widoczności w Google — jak zaplanować redirecty 301, zanim wyłączysz stary serwis.

Maciej Sala

Maciej Sala

Founder Strivelab

11 kwietnia 2026
Poprzedni wpisGoogle Search Console + Next.js — indeksacja, błędy, performance i co z nimi robićGSC dla strony Next.js — jak czytać dane o indeksacji, rozumieć błędy crawlowania i reagować na spadki, zanim ruch organiczny zniknie.
Maciej Sala

Maciej Sala

Founder Strivelab

11 kwietnia 2026
Następny wpisMigracja z WordPress do Next.js — redirecty 301 i pozycje SEOMigracja z WordPress do Next.js bez utraty widoczności w Google — jak zaplanować redirecty 301, zanim wyłączysz stary serwis.
Maciej Sala

Maciej Sala

Founder Strivelab

11 kwietnia 2026