SEO aplikacji jednostronicowych (SPA)

Jak sprawić, aby aplikacje jednostronicowe (React Router, Vue Router, Angular Router) były crawlable i indeksowalne — problemy app shell i soft 404, History API kontra routing z haszem, HTML dla każdej trasy przez SSR/prerendering, canonicale i tytuły dla tras oraz generowanie map witryn dla tras po stronie klienta.

Opublikowano po raz pierwszy: 3 lip 2026 · Ostatnia aktualizacja: 20 sie 2026 · Advanced
Języki

Aplikacja jednostronicowa ładuje jeden dokument i przełącza widoki za pomocą JavaScriptu zamiast żądać nowej strony od serwera. Wiele SPA implementuje to przez app shell — serwer zwraca rzeczywisty HTML dla jednego URL-a albo niemal pustą powłokę, dopóki JS nie wyrenderuje każdej trasy — ale jest to częsty wybór implementacyjny, a nie reguła dla każdego SPA; zanim coś założysz, sprawdź bezpośrednie żądanie każdej trasy. Jeśli problemem jest goła konfiguracja app shell, naprawa ma dwie niezależne połowy: adresowalność (History API zamiast fragmentów hash/#!, aby każdy widok miał prawdziwy URL — miękka nawigacja przeglądarki przez History API zmienia URL i interfejs, ale sama nie tworzy nowej odpowiedzi serwera) oraz dostępność treści (SSR, prerendering albo meta-framework, dzięki któremu każdy URL może zwrócić unikalny HTML). Ponadto sprawdzaj tytuł, canonical i stan robots każdej trasy przy bezpośrednim wejściu i po renderowaniu, obsługuj miękkie błędy (routery często zachowują status 200 dla widoków „nie znaleziono” — przekieruj do URL-a, który sam zwraca błąd, albo dodaj wyrenderowany noindex, choć początkowy noindex może spowodować pominięcie renderowania) i upewnij się, że każda trasa w mapie witryny rozwiązuje się do prawdziwego, indeksowalnego HTML-a — nie ma specjalnego formatu mapy SPA.

TL;DR — Główne ryzyko SEO SPA nie polega abstrakcyjnie na „JavaScripcie”, lecz na tym, że routing po stronie klienta zmienia to, co widzi użytkownik, bez zmiany tego, co zwróciłby serwer. Naprawa ma dwie niezależne połowy: adresowalność (History API, unikalne adresy URL dla tras, brak fragmentów #!) oraz dostępność treści (SSR, prerendering/SSG albo meta-framework). Zrób tylko pierwszą połowę, a otrzymasz uporządkowaną mapę adresów URL, z których każdy renderuje tę samą powłokę. Dodatkowo każda trasa potrzebuje własnego canonicala, tytułu i opisu w wyrenderowanym DOM, obsługi miękkich błędów z kodem 200, a każdy adres URL w mapie musi niezależnie zwracać prawdziwy HTML.

Czym naprawdę jest SEO SPA

SPA to architektura aplikacji, a nie gwarantowany stan awarii SEO. Evidence for this claim Primary standard or official documentation supporting the adjacent article claim. Scope: Protocol semantics and Search behavior are kept separate; no indexing, ranking, or migration-timing guarantee is inferred. Confidence: high · Verified: MDN: Single-page application Renderowanie serwerowe, prerendering albo starannie wdrożone renderowanie po stronie klienta mogą udostępnić treść, ale żadne z nich nie gwarantuje indeksowania. Evidence for this claim Primary standard or official documentation supporting the adjacent article claim. Scope: Protocol semantics and Search behavior are kept separate; no indexing, ranking, or migration-timing guarantee is inferred. Confidence: high · Verified: Google: JavaScript SEO basics

To szczegółowe omówienie aplikacji jednostronicowych — routingu po stronie klienta z React Routerem, Vue Routerem lub Angular Routerem, gdzie po pierwszym załadowaniu serwer nie wysyła już pełnej strony. Artykuł uzupełnia szerszy przewodnik po JavaScript SEO oraz teksty o React, Next.js, Nuxt, Angularze, Vue, Svelte i Astro. Interesuje mnie tu wyłącznie warstwa routingu i jej wpływ na crawlowanie oraz indeksowanie.

Dlaczego puste SPA z routingiem po stronie klienta zawodzi w SEO

Jeden adres URL, jedna odpowiedź HTML — problem app shell

Google dokładnie opisuje ten tryb awarii: “Some JavaScript sites may use the app shell model where the initial HTML does not contain the actual content and Google needs to execute JavaScript before being able to see the actual page content.” (tłumaczenie) „Niektóre witryny korzystające z JavaScriptu mogą używać modelu app shell, w którym początkowy HTML nie zawiera właściwej treści, więc Google musi wykonać JavaScript, aby ją zobaczyć.”. W pustym SPA app shell jest wszystkim, co kiedykolwiek zwraca serwer. Pobierz świeżo /products, a dostaniesz ten sam niemal pusty dokument co dla /about. Treść rozdziela się dopiero po uruchomieniu JavaScriptu w przeglądarce i podjęciu decyzji przez router.

To cały problem w jednym zdaniu: routing po stronie klienta zmienia to, co widzi użytkownik, bez zmiany tego, co zwróciłby serwer. Wszystko, co dzieje się później — miękkie błędy, duplikaty treści i brakujące tytuły — jest objawem tego problemu.

Serwer nie widzi, o którą „stronę” poproszono — dlaczego routing z haszem zawodzi

Starsze SPA ładowały widoki z fragmentów adresu URL — example.com/#/products. Google mówi dokładnie: “A SPA may use URL fragments (for example https://example.com/#/products) for loading different views.” (tłumaczenie) „SPA może używać fragmentów URL-a do ładowania różnych widoków.”. Zawodzi to z mechanicznego powodu: przeglądarka nigdy nie wysyła fragmentu, czyli niczego po znaku #, w żądaniu HTTP do serwera. Serwer nie wie, o którą stronę poproszono, więc nie może zwrócić innej treści ani innego kodu statusu. Crawler, który pobierze adres raz, otrzyma identyczny HTML niezależnie od fragmentu.

Dlatego Google formalnie wycofało schemat crawlowania AJAX z 2009 roku w październiku 2015: “In short: We are no longer recommending the AJAX crawling proposal we made back in 2009.” (tłumaczenie) „Krótko mówiąc, nie zalecamy już schematu crawlowania AJAX zaproponowanego w 2009 roku.”. Dawne obejście _escaped_fragment_ pozwalało serwerom generować trasy z fragmentami na żądanie, ale omijało problem routingu zamiast go naprawiać. Zastępująca je rekomendacja Google to History API, omówione niżej.

Miękkie błędy — routery po stronie klienta utrzymują status 200 dla wszystkiego

W pustym SPA ten problem jest niemal nieunikniony. Routery po stronie klienta z założenia zachowują pierwotny status 200 dla każdej wirtualnej nawigacji — także dla stanów „nie znaleziono”. Google wyraźnie to sygnalizuje: “In a single-page application (SPA), this can be especially difficult. To prevent error pages from being indexed, you can use one or both of the following strategies.” (tłumaczenie) „W SPA jest to szczególnie trudne. Aby zapobiec indeksowaniu stron błędów, można zastosować jedną z poniższych strategii lub obie.”. Powód jest prosty: “When a SPA is using client-side JavaScript to handle errors they often report a 200 HTTP status code instead of the appropriate status code.” (tłumaczenie) „SPA obsługujące błędy za pomocą JavaScriptu po stronie klienta często zgłasza kod HTTP 200 zamiast odpowiedniego kodu stanu.”.

W efekcie puste lub błędne widoki są indeksowane jako cienkie strony 200. Google dokumentuje tu dokładnie dwie strategie: przekieruj do adresu URL, którego serwer zwraca prawdziwy status 404/błędu, albo dodaj do widoku błędu tag noindex za pomocą JavaScriptu. Jeśli noindex jest obecny już przy pierwszym renderowaniu, Google może całkowicie pominąć renderowanie. Sprawdź bezpośrednią odpowiedź przed uruchomieniem JS, a nie tylko późniejszy wyrenderowany DOM.

Evidence for this claim For a client-rendered not-found view, Google documents two approaches: redirect to a URL whose server returns a 404 response, or add noindex with JavaScript; an initial noindex can cause rendering to be skipped, so raw and rendered directives require separate testing. Scope: SPAs Confidence: high · Verified: Fix Search-related JavaScript problems

Duplikaty wspólnej powłoki — możliwa przyczyna, nie automatyczna diagnoza

Istnieje drugi, bardziej podstępny tryb awarii: różne trasy są indeksowane jako duplikaty, ponieważ po wyrenderowaniu sprowadzają się do tej samej wspólnej warstwy nagłówka, nawigacji i stopki. Gary Illyes opisał jeden ze sposobów: “I have a bunch of emails in my inbox where the issue is that the centerpiece took forever to load, so rendering timed out (my most likely explanation) and we were left with a bunch of pages that only had the boilerplate. With only the boilerplate, those pages are dups.” (tłumaczenie) „Mam w skrzynce wiele wiadomości, w których główna treść ładowała się tak długo, że renderowanie przekroczyło limit czasu — to moje najbardziej prawdopodobne wyjaśnienie — i pozostało wiele stron zawierających tylko szablon. Z samym szablonem strony te są duplikatami.”. Jego rada brzmi: “Try to restructure the js calls such that the content (including marginal boilerplate) loads first.” (tłumaczenie) „Spróbuj zmienić kolejność wywołań JavaScriptu tak, aby treść, w tym poboczne elementy szablonu, ładowała się jako pierwsza.”. (Przekazane w poście na LinkedIn; traktuj jako źródło wtórne o wysokiej wiarygodności.)

Zwróć uwagę, że Illyes określa to jako “my most likely explanation” (tłumaczenie) „moje najbardziej prawdopodobne wyjaśnienie”, a nie potwierdzoną diagnozę. Same objawy nie wystarczą, by rozpoznać “Render timeout” (tłumaczenie) „przekroczenie limitu czasu renderowania”: potrzebujesz dowodów z bezpośredniego żądania, wyrenderowanego wyniku i Search Console, zanim przypiszesz duplikację tras właśnie limitowi czasu, a nie błędowi, zablokowanemu zasobowi lub identycznej powłoce. Nie istnieje też stały, opublikowany limit, pod który warto projektować. Dokumentacja Google mówi, że strona “may stay on this queue for a few seconds, but it can take longer than that,” (tłumaczenie) „może pozostać w tej kolejce przez kilka sekund, ale może to potrwać dłużej”, nie podając konkretnej wartości. Dlatego główna treść powinna ładować się możliwie wcześnie niezależnie od czasu renderowania.

Naprawa: prawdziwy HTML dla każdej trasy

Pełna naprawa ma dwie niezależne części, które ludzie stale mieszają:

  1. Adresowalność URL — History API, unikalne adresy URL tras, brak fragmentów.
  2. Dostępność treści — SSR, statyczny prerendering/SSG albo meta-framework.

Zrób tylko punkt 1, a otrzymasz czyste adresy URL, które nadal zwracają tę samą pustą powłokę. Dla trasy, którą chcesz indeksować, potrzebujesz obu elementów.

Nie jest to jednak uniwersalny nakaz dodawania SSR wszędzie. SSR i prerendering zmniejszają zależność od pomyślnego wykonania JavaScriptu przez crawlera. Przed wyborem architektury sprawdź, czy trasa musi się pozycjonować, co zwraca bezpośrednie żądanie bez JS oraz jakie crawlery musisz obsłużyć. CSR, który przechodzi te testy, nie musi stawać się SSR tylko dlatego, że aplikacja jest SPA.

Renderowanie po stronie serwera (SSR)

Serwer uruchamia aplikację dla każdego żądania i zwraca w pełni utworzony HTML dla danej trasy, po czym klient nawodnia go do działającej aplikacji SPA. To najbardziej niezawodna opcja, bo crawler dostaje pełną treść przy pierwszym pobraniu i nie musi uruchamiać JavaScriptu.

Statyczny prerendering / SSG

Zamiast renderować przy każdym żądaniu, budujesz HTML każdej trasy z wyprzedzeniem podczas wdrożenia. To idealne rozwiązanie dla treści, które nie zmieniają się zależnie od użytkownika. Każdy wariant SSR, renderowania statycznego i prerenderingu będzie dobry dla wyszukiwarek — należy unikać pozostawiania treści za renderowaniem wyłącznie po stronie klienta.

Albo: nie twórz tego ręcznie — użyj meta-frameworka

W nowym projekcie uczciwie rekomenduję, aby nie tworzyć ręcznie routingu po stronie klienta wyłącznie z React Routera ani Vue Routera. Meta-framework — Next.js, Nuxt, Angular z pakietem SSR, SvelteKit czy Remix — daje SSR/SSG i metadane zależne od trasy od razu. Dodanie SSR do istniejącego, ręcznie zbudowanego SPA to prawdziwa praca inżynierska — projekt migracyjny, a nie przełącznik konfiguracji.

Dynamiczne renderowanie jako rozwiązanie tymczasowe, nie cel

Crawlerom można serwować osobno wyrenderowany zrzut HTML (dynamic rendering). Akceptują to Google i Bing — Bing “recommend[s] dynamic rendering as a great alternative for websites relying heavily on JavaScript” (tłumaczenie) „zaleca dynamiczne renderowanie jako dobrą alternatywę dla witryn silnie zależnych od JavaScriptu” (przekazane z bloga Binga z 2018 roku; tego dłuższego cytatu nie sprawdzano ponownie) — ale Google jasno mówi, że to obejście: “Dynamic rendering was a workaround and not a long-term solution… Instead, we recommend that you use server-side rendering, static rendering, or hydration as a solution.” (tłumaczenie) „Dynamiczne renderowanie było obejściem, a nie długoterminowym rozwiązaniem. Zamiast niego zalecamy renderowanie po stronie serwera, renderowanie statyczne lub hydrację.” (przekazane z dokumentacji Google o dynamicznym renderowaniu). To most, nie architektura.

History API kontra routing z haszem (#!)

Pięć stanów, nie dwa

Testowanie trasy SPA jest mylące, ponieważ pytanie „czy działa?” obejmuje pięć różnych, niezależnie weryfikowalnych stanów:

StanCzym jest
Odpowiedź serweraBajty, które faktycznie zwraca świeże żądanie HTTP bez JS do adresu URL.
Wyrenderowany DOMTo, co buduje przeglądarka albo renderer Googlebota po wykonaniu JavaScriptu.
Przetwarzanie wyszukiwarkiSposób, w jaki Google crawluje odpowiedź serwera, później renderuje stronę i indeksuje ją na podstawie obu wyników.
Pełna nawigacja dokumentuNowe żądanie HTTP do adresu URL — jedyny stan, który może zmienić odpowiedź serwera lub kod statusu.
Miękka nawigacjaPrzejście History API pushState/replaceState, które zmienia adres URL, historię przeglądarki i interfejs.

Miękka nawigacja zmienia adres URL i interfejs, ale sama nie tworzy nowej odpowiedzi HTTP ani statusu — dzieje się to dopiero przy pełnej nawigacji albo równoważnym żądaniu bezpośrednim, takim jak curl. Oba testy są ważne i mogą dawać różne wyniki.

Co daje History API

Rekomendacja Google jest jednoznaczna: “We recommend using the History API to load different content based on the URL in a SPA.” (tłumaczenie) „Zalecamy używanie History API do ładowania różnych treści na podstawie URL-a w aplikacji SPA.” (W aktualnej dokumentacji „History API” jest linkiem, dlatego ten deep link wskazuje klauzulę wprowadzającą.). History API pushState/replaceState pozwala routerowi zmienić widoczny adres URL na prawdziwą, możliwą do zapisania ścieżkę — /products, a nie /#/products — bez pełnego przeładowania. Naprawia to połowę dotyczącą adresowalności.

Dlaczego fragmenty i hasze są niewidoczne dla serwera — i Google

Ponieważ fragment nigdy nie dociera do serwera, History API jest sposobem nadania każdej trasie adresu URL, który serwer może obsłużyć. Google zaleca: “don’t use fragments to load different page content. The following example is a bad practice, because Googlebot can’t reliably resolve the URLs.” (tłumaczenie) „nie używaj fragmentów do ładowania różnych treści strony; poniższy przykład jest złą praktyką, ponieważ Googlebot nie potrafi niezawodnie rozpoznawać tych URL-i.”. Używaj zwykłych adresów, takich jak /products, a nie adresów z haszem /#/products, bo Google nie potrafi ich niezawodnie indeksować.

Krótko o wycofaniu z 2015 roku

Era hash-bang (#!) skończyła się wpisem Google z 2015 roku: “Times have changed. Today, as long as you’re not blocking Googlebot from crawling your JavaScript or CSS files, we are generally able to render and understand your web pages like modern browsers,” (tłumaczenie) „Obecne realia są inne: jeśli Googlebot nie ma zablokowanego dostępu do plików JavaScript lub CSS, zazwyczaj potrafimy renderować i rozumieć strony internetowe tak jak współczesne przeglądarki.” oraz “you can use the History API pushState() to ensure accessibility for a wider range of browsers (and our systems).” (tłumaczenie) „możesz użyć History API pushState(), aby zapewnić dostępność dla większej liczby przeglądarek oraz naszych systemów.”. Google nie usunęło natychmiast starych witryn z hash-bang — “we’ll generally crawl, render, and index the #! URLs” (tłumaczenie) „zazwyczaj będziemy crawlować, renderować i indeksować URL-e z #!.” — ale „nadal możemy próbować” nie znaczy „nadal powinieneś tak robić”.

Spraw, aby każda trasa była niezależnie indeksowalna

Czyste adresy URL i prawdziwy HTML zapewniają crawlowanie. Aby uzyskać poprawne indeksowanie, każda trasa potrzebuje własnych sygnałów.

Tagi canonical dla każdej trasy — pułapka najbardziej restrykcyjnej dyrektywy

Każda trasa potrzebuje własnego rel=canonical w wyrenderowanym DOM. Zastępczy canonical albo noindex może zostać wysłany w surowej powłoce HTML, a JavaScript ma go nadpisać później. Google wybiera bardziej restrykcyjny sygnał. Zbędny noindex albo błędny canonical zaszyty w powłoce może po cichu wyłączyć całą trasę.

Tytuły i opisy meta dla każdej trasy ustawiane przez JavaScript

Ustawianie ich w JS jest poprawne — Google mówi wprost: “You can use JavaScript to set or change the meta description as well as the <title> element.” (tłumaczenie) „Możesz użyć JavaScriptu do ustawienia lub zmiany opisu meta oraz elementu <title>.”. Warunek jest taki, aby trafiły do wyrenderowanego DOM-u ocenianego przez Google. Każda strona powinna mieć własny tytuł i opis aktualizowany przy zmianie trasy.

Prawdziwe linki <a href> między trasami

Linki między trasami twórz jako prawdziwe kotwice z atrybutami href, a nie obsługiwane kliknięciem elementy <div>. Google odkrywa adresy URL, wyciągając wartości href; nawigacja przez <div onClick> jest niewidoczna dla ekstrakcji linków. Nie przenoś też treści między nawigacjami wyłącznie za pomocą stanu klienta — renderer Google “does not retain state across page loads: Local Storage and Session Storage data are cleared across page loads. HTTP Cookies are cleared across page loads.” (tłumaczenie) „nie zachowuje stanu między wczytaniami stron: dane Local Storage i Session Storage oraz pliki cookie HTTP są czyszczone przy kolejnych wczytaniach.”.

Generowanie mapy witryny dla tras SPA

Nie ma specjalnego formatu mapy SPA — to problem ewidencyjny

Protokół map witryn dla SPA się nie zmienia. Trzeba dopilnować, aby każda wymieniona trasa niezależnie zwracała prawdziwy, unikalny, wyrenderowany HTML. Mapa 500 tras po stronie klienta jest bezwartościowa, jeśli wszystkie zwracają tę samą powłokę.

Obsługa tras dynamicznych i parametryzowanych (/product/:id)

W aplikacji z trasami parametryzowanymi nie da się ręcznie utrzymywać listy. Generator mapy musi korzystać z tego samego źródła danych co aplikacja — ze skryptu budowania albo endpointu serwera wyliczającego każde id — aby mapa i aplikacja nigdy się nie rozjechały. Warto wymieniać te adresy URL dopiero wtedy, gdy są renderowane przez SSR albo prerenderowane.

Utrzymanie zgodności mapy z tym, co może zwrócić serwer

Regeneruj mapę podczas budowania albo według harmonogramu powiązanego ze źródłem treści. Trasa, która zwraca błąd albo miękki błąd z 200, lecz pozostaje w mapie, wysyła niepożądany sygnał dotyczący budżetu crawlowania i jakości.

Testowanie tego, co faktycznie widzi Google

Nie sprawdzaj wyłącznie jednego adresu URL. Istota problemu SPA polega na tym, że trasy mogą różnić się w przeglądarce, ale nie w odpowiedzi sieciowej, dlatego testuj wiele tras na poziomie surowego HTML:

  • Pobierz surowy HTML każdej trasy poleceniem curl i potwierdź, że treść jest unikalna dla adresu URL, a nie stanowi wspólnej powłoki.
  • Inspekcja adresu URL w Search Console — porównaj crawlowany i wyrenderowany HTML kilku tras oraz potwierdź obecność ich tytułu, opisu i canonicala.
  • Potwierdź właściwy sygnał dla tras błędów — widok „nie znaleziono” powinien przekierowywać do prawdziwego statusu błędu albo zawierać noindex w wyrenderowanym DOM.

Typowe mity o SEO SPA

  • „Google w ogóle nie indeksuje SPA”. Nieaktualne. Google zwykle renderuje treść SPA. Prawdziwe ryzyka to miękkie błędy, routing z haszem, timeouty renderowania i crawlery inne niż Google.
  • „Dodanie History API naprawia SEO SPA”. Naprawia wyłącznie adresowalność. Jeśli serwer nadal zwraca tę samą powłokę dla każdej trasy, Google wciąż musi uruchomić JS.
  • „Routing z haszem działa jako plan awaryjny”. Wycofany od 2015 roku.
  • „Renderowanie po stronie klienta jest karą rankingową”. Nie ma bezpośredniej kary za CSR; szkoda jest pośrednia — nieudane lub opóźnione renderowanie, miękkie 404 i duplikaty.
  • „Prerendering dla botów to cloaking”. Nie, jeśli treść zgadza się z tym, co widzą użytkownicy.
  • „Potrzebujesz specjalnego generatora map SPA”. Nie istnieje specjalny format; każda wymieniona trasa musi zwracać prawdziwy HTML.

FAQ

Czy Google może indeksować aplikację jednostronicową? Tak, jeśli każda trasa prowadzi do prawdziwego, unikalnego HTML-a przez SSR lub prerendering pod prawdziwym adresem URL. Puste SPA działające wyłącznie po stronie klienta często tego nie zapewniają.

Czy potrzebuję SSR, czy renderowanie po stronie klienta bywa w porządku? CSR może działać dla treści, które nie muszą się pozycjonować, ale wszystko, co chcesz niezawodnie indeksować, prerenderuj albo renderuj przez SSR.

Czy routing z haszem (#!) jest zły dla SEO? Tak — fragment nigdy nie dociera do serwera, więc Google nie potrafi niezawodnie rozwiązać takich adresów. Użyj History API.

Czy każda trasa potrzebuje własnego canonicala? Tak, w wyrenderowanym DOM-ie — dopilnuj, aby w surowej powłoce nie pojawił się przypadkowy noindex albo błędny canonical.

Czy usługa prerenderingu to cloaking? Nie, pod warunkiem że treść serwowana botom zgadza się z tym, co widzą użytkownicy.

Czy powinienem użyć meta-frameworka zamiast samodzielnie budować routing? Przy nowym projekcie zwykle tak — Next.js, Nuxt, Angular SSR i SvelteKit oferują SSR/SSG oraz metadane tras.

Dlaczego moje SPA zwraca 200 dla nieistniejących stron? Router po stronie klienta zachowuje pierwotny status 200 dla wirtualnej nawigacji. Napraw to przekierowaniem JS do prawdziwego statusu błędu albo wyrenderowanym noindex.

Add an expert note

Pin an expert quote

New person? Create their unclaimed profile at /admin/experts/ → Pin a quote first.