JS SEO Lab: empiryczne badanie wzorców JavaScript w erze botów LLM

JS SEO Lab (jsseo.dev) to otwarte laboratorium badawcze prowadzone przez Jakuba Sawę, mierzące widoczność ośmiu wzorców JavaScript (clean, js-images, js-links, click-reveal, js-fetched, hash-routing, late-loaded, mixed) w dwudziestu siedmiu klasach botów. W tym batchowe crawlery AI (GPTBot, ClaudeBot, PerplexityBot, Bytespider, CCBot, Meta-ExternalAgent), on-demand fetchery uruchamiane przez prompty użytkowników LLM (ChatGPT-User, Claude-User, Claude-SearchBot, Perplexity-User, Bing Copilot) oraz klasyczne wyszukiwarki (Googlebot, Bingbot, Applebot). Testowanie odbywa się na pięciu typach stron (homepage, artykuł, kategoria, produkt, wyniki wyszukiwania) w trybach renderowania SSR, CSR i SSG, na żywym test bedzie Next.js pod adresem next.jsseo.dev. Trójwarstwowy tracking: middleware Next.js, ingester Cloudflare GraphQL Analytics, beacon JavaScript po hydration. Weryfikacja botów przez reverse DNS (Google, Bing, Apple) oraz IP-range CIDR matching przeciw publikowanym manifestom (OpenAI, Perplexity, Anthropic). Sześć hipotez pre-rejestrowanych w publicznym repozytorium GitHub przed rozpoczęciem zbierania danych. Dane na licencji CC0, kod na MIT, teksty na CC-BY. Phase 1 startuje w maju 2026, pierwsze findings: czerwiec 2026.

Co kilka miesięcy ktoś pisze do mnie z tą samą wiadomością, w różnych wariacjach, ale zawsze o tym samym: „Jakub, zrobiliśmy migrację na Next.js, połowa kart produktów nie pojawia się w ChatGPT. Treść w przeglądarce widać normalnie. Co się dzieje?„.

Albo: „treść artykułu nie ląduje w Perplexity, choć Google ją indeksuje”. Albo: „audyt agencji zewnętrznej mówi że mamy problem z renderowaniem, ale konkretu zero”.

W żadnej z tych wiadomości nie pojawiają się słowa „rendering mode”, „hydration”, „SSR vs CSR”. Nikt nie pyta o framework w abstrakcyjnym sensie. Pytanie zawsze jest konkretne: dlaczego dana informacja na danej stronie nie dociera do danego bota.

A ja przez sześć tygodni budowałem stanowisko testowe, które miało odpowiedzieć na zupełnie inne pytanie. Na pytanie, na które odpowiedź już istnieje od dwóch lat.

Dlatego dzisiaj wszystko skasowałem i zaczynam od nowa. Tym razem mierzę to, co rzeczywiście psuje audyty u klientów. Projekt nazywa się JS SEO Lab, żyje pod domeną jsseo.dev, jest otwarty od pierwszego dnia. Tłumaczę poniżej, co zbudowałem, co zniszczyłem, co teraz mierzę i dlaczego myślę, że to jest najciekawsza rzecz, którą tej zimy zrobię.

Czego nie musiałem mierzyć

W 2024 roku Vercel i MERJ opublikowali The Rise of the AI Crawler. Zinstrumentowali edge Vercela, zmierzyli miliardy requestów od batchowych crawlerów AI: GPTBot, ClaudeBot, PerplexityBot, Bytespider, CCBot, Meta-ExternalAgent. Plus Googlebot jako referencja.

Wynik jest mocny i prosty. Batchowe crawlery AI nie wykonują JavaScriptu. Nie „rzadko”, nie „z opóźnieniem”. Nie wykonują, kropka. GPTBot pobiera pliki .js w 11.5% requestów i nigdy ich nie uruchamia. ClaudeBot w 23.8%, też nie uruchamia. PerplexityBot, Bytespider, CCBot, cała ta klasa botów czyta HTML, ignoruje skrypty, leci dalej.

Kiedy to wiesz, pytanie o tryb renderowania staje się trywialne. SSR, SSG, ISR, RSC streaming, Edge SSR – wszystkie zwracają statyczny HTML w pierwszej odpowiedzi. Dla bota który nie wykonuje JS są nieodróżnialne. Jedynym wyjątkiem jest CSR, gdzie serwer zwraca pustą skorupę i treść przychodzi po stronie klienta. Wszystkie pozostałe tryby renderowania serwerowego z perspektywy nie-JS bota wyglądają identycznie.

A ja przez sześć tygodni budowałem stanowisko, na którym chciałem mierzyć osie SSR vs SSG vs ISR vs RSC vs Edge SSR vs CSR. Sześć trybów razy pięć typów stron razy warianty obrazów. Sto czterdzieści cztery cele do pomiaru. Tracker postawiony, dashboard działał, GPTBot już chodził po stronach.

Gdybym to dokończył, opublikowałbym za pół roku post pod tytułem „potwierdzamy ustalenia Vercel/MERJ na nowym datasecie”. Replikacja ma wartość. Ale to nie jest to, czego potrzebują klienci.

Czego naprawdę nie wiemy

Bo prawdziwy audyt techniczny nigdy nie zaczyna się od pytania „czy tryb renderowania jest dobry”. Zaczyna się od konkretnych objawów:

Cena produktu nie pojawia się w wynikach ChatGPT, choć w przeglądarce widać ją natychmiast. Sekcja FAQ z mechanizmem „kliknij, żeby rozwinąć” zniknęła z Google. Nawigacja używa <button onClick={navigate}> zamiast <a href>, bo design team chciał animacji hover – i nagle linki wewnętrzne przestały działać dla crawlera. Lista produktów w kategorii pobierana jest przez fetch('/api/products') po hydration – i z perspektywy GPTBota strony kategorii są puste.

To wszystko są pytania o wzorzec JS na stronie, nie o tryb renderowania. Ta sama strona w SSR może zawierać:

  • <img> którego src ustawia się dopiero po mount przez useEffect
  • <button onClick> udający link, bez prawdziwego href
  • „Pokaż więcej” zasłaniający treść artykułu do kliknięcia
  • fetch('/api/...') po stronie klienta zasilający cenę w karcie produktu
  • nawigację #/tab-a zamiast realnych ścieżek
  • IntersectionObserver odsłaniający blok powiązanych artykułów

Każdy z tych elementów żyje w trybie SSR. W mojej oryginalnej macierzy sto czterdziestu czterech cel każdy z nich wyglądałby identycznie. To, co bot widzi albo czego nie widzi, zależy od konkretnego wzorca, nie od trybu.

Osiem wzorców

Skróciłem to do katalogu ośmiu. Są to wzorce, które najczęściej widzę w realnych audytach, i które są na tyle rozłączne, że sygnał widoczności da się interpretować osobno dla każdego z nich.

clean. Baseline. Cała treść w SSR HTML, zero JS-injected content. Punkt odniesienia.

js-images. Atrybut src na obrazku ustawiany po stronie klienta przez useEffect. To częsta konsekwencja użycia setterów React, gdzie initial state jest pustym stringiem, a React 19 w ogóle pomija atrybut src w renderze SSR jeśli wartość to pusty string. Crawler bez JS dostaje tag <img> bez czego pobierać.

js-links. Nawigacja przez <button onClick={router.push}> zamiast <a href>. Designerski klasyk z ostatnich dwóch lat. Dla użytkownika identyczne, dla bota niewidzialne.

click-reveal. Treść główna ukryta do momentu kliknięcia „pokaż więcej”. Stosowane na FAQ, opisach produktów, rozwijanej treści artykułów.

js-fetched. Treść pobierana po stronie klienta z API po mount. Ceny w karcie produktu. Recenzje. Stock. Polecane produkty.

hash-routing. URL fragmentami #/path zamiast realnych ścieżek /path. Stary spadek po legacy SPA, ale wciąż czasem trafia do nowych projektów.

late-loaded. Treść renderowana dopiero po wystrzeleniu IntersectionObservera. Pakuje się tutaj większość lazy-loaded sekcji typu „powiązane artykuły”, „ostatnio oglądane”, „klienci kupują też”.

mixed. Realistyczna kombinacja powyższych. Karta produktu z js-fetched ceną, lazy obrazem i click-reveal recenzjami. Bo w realu nikt nie robi czystego wzorca w izolacji.

Każdy z ośmiu wzorców jest testowany na pięciu typach stron (homepage, artykuł, kategoria, produkt, wyniki wyszukiwania) w trybie SSR. CSR działa jako negative control. SSG jako sanity check. Razem 55 cel zamiast 144, i każda z nich odpowiada na konkretne pytanie audytowe.

Cele są na żywo pod next.jsseo.dev. Wejdź na jakąkolwiek z nich, zrób view source, zobaczysz wzorzec w surowej postaci. W cellach js-images żaden <img> w pierwszym HTML nie ma atrybutu src – React 19 go pomija, bo wartość stanu jest pustym stringiem. Crawler bez JS dostaje tag bez nic.

Trzy warstwy trackingu

Sam pomiar to drugi problem. Bo „GPTBot odwiedził cellę pięć razy” mówi mi tyle co nic. Chcę wiedzieć, czy GPTBot zobaczył treść, czy tylko placeholder loadingu.

Tracker jsseo.dev stoi na trzech niezależnych warstwach, które się dublują i krzyżują:

Layer 1. Middleware Next.js loguje każdy hit zanim odpowiedź wyjdzie z workera. Fire-and-forget POST do track.jsseo.dev/api/hit przez ctx.waitUntil(). To jest podstawowy strumień.

Layer 2. Ingester z Cloudflare GraphQL Analytics dociąga do trackera te requesty, które Cloudflare zalogował na edge’u, ale które nie poszły przez worker (statyki prerender, edge cache hits). Sześćdziesięciosekundowy poll lag, deduplikacja przez naturalny klucz haszowany. To łapie hits które Layer 1 by przepuścił.

Layer 3. Mały <script> w HTML, który po hydration robi POST do /api/js-executed. Boty wykonujące JavaScript wystrzelą beacon. Boty bez wykonania JS go nie wystrzelą. Tracker joinuje eventy beacona z hitami przez marker UUID per cella. Dashboard pokazuje wtedy js_executed_pct per (wzorzec, klasa bota). To jest właściwy sygnał przeżywalności wzorca.

Layer 3 jeszcze nie wystartował. Infrastruktura gotowa, brakuje samego beacona i migracji schematu pod eventy. Szacuję dwie godziny pracy. Oddzieliłem to świadomie od sprintów architektonicznych, żeby zwalidować jako dyskretny krok.

Identyfikacja botów

Dwadzieścia siedem klas botów rozpoznawanych, trzy ścieżki weryfikacji:

rDNS dla Google, Bing, Apple. Reverse DNS plus forward confirmation przeciw publikowanej domenie bota (*.googlebot.com, *.search.msn.com, *.applebot.apple.com).

IP-range dla OpenAI, Perplexity, Anthropic. CIDR membership check przeciw publikowanym manifestom JSON odświeżanym co sześć godzin. Plus hardcoded lista dla Anthropic, bo Anthropic publikuje swoje zakresy jako stronę HTML, nie JSON.

none / auto-unverified. Deprecated user agenty sprzed 2024 (anthropic-ai, Claude-Web) automatycznie flagowane jako niewiarygodne na insercie. Nie trafiają do kolejki weryfikacji, ich liczby są widocznie odseparowane w dashboardzie.

Wszedł też detection nagłówków HTTP Message Signature (RFC 9421) dla podpisanych requestów ChatGPT Agenta. Weryfikacja podpisów przeciw publikowanym key setom czeka w kolejce, na razie tylko wykrywam obecność.

LLM fetch testing

Równolegle z trackingiem botów chodzi drugi pomiar. Periodyczne testy promptowe, w których pytam dziewięć powierzchni LLM, co widzą gdy każę im pobrać konkretny URL z test beda. ChatGPT, Claude, Perplexity, Gemini, Bing Copilot. Web UI i API gdzie dostępne.

Klasa A promptów mówi modelowi: „pobierz tę stronę, opisz co widzisz”. Każda odpowiedź oceniana jest w skali 0-4 (fetch failed, placeholder only, partial, full but inaccurate, full and accurate) z binarnymi flagami marker_detected, images_described, structured_data_used.

Kadencja: tygodniowy sweep przez wszystkie celle razy wszystkie powierzchnie. Codzienne sondowanie 24 high-signal cel. Manualne próbkowanie pięć do dziesięciu cel tygodniowo w czatowych UI, żeby łapać rozbieżności między API a interfejsem czatowym.

Sześć hipotez i kill criterion

Sześć hipotez zacommitowanych w repo zanim padł pierwszy hit:

H1 (sanity check). clean SSR ≡ clean SSG dla batch crawlerów. Jeśli to się posypie, metodologia jest popsuta.

H2. clean SSR w pełni widoczny dla wszystkich batch AI crawlerów. GPTBot, ClaudeBot, PerplexityBot, Bytespider, CCBot.

H3 (główny test). Wzorce js-images, js-links, click-reveal, js-fetched, hash-routing, late-loaded produkują niewidzialny content główny dla batch AI crawlerów.

H4. Googlebot (i Gemini, dzielący infrastrukturę) eventualnie wyrenderuje wszystkie osiem wzorców, z mierzalnym opóźnieniem per-wzorzec względem clean.

H5. On-demand fetchery (ChatGPT-User, Claude-User, Claude-SearchBot, Perplexity-User, Bing Copilot) – zachowanie eksploracyjne, brak silnego prior. Split między batchowym crawlerem (GPTBot) a jego on-demand siostrą (ChatGPT-User) dla tego samego dostawcy to najbardziej obserwowany sygnał całego projektu.

H6. Rendering JS przez Bing jest niespójny. Pierwsze systematyczne dane o Bingu od czasu notki o crawlerze Edge z 2019 roku.

Kill criterion: jeśli wzorzec js-images nie różni się znacząco od clean dla batch AI crawlerów, metodologia jest popsuta i wstrzymuję wszystko. Vercel/MERJ 2024 stanowi mocną linię bazową, ta predykcja powinna się spełnić.

H5 jest dla mnie najbardziej napiętą. Nie mam silnego prior. Wiemy, że batch crawlery nie wykonują JS. Nie wiemy systematycznie, co robią on-demand fetchery wywoływane wtedy, gdy użytkownik pyta LLM o coś konkretnego. Wykonują JS? Nie wykonują? Robią to inaczej dla GET niż dla POST? Mają inną agendę cache niż GPTBot? Tego nikt publicznie nie zmierzył jeszcze.

To jest dla mnie właściwy temat.

Otwarte od pierwszego dnia

Cały projekt jest publiczny od początku. Repozytorium na github.com/Qbeczek1/jsseo-dev. Źródła test beda w apps/next/. Źródła tracker servera w tracker-server/. Surowe dane trackera publikowane są w repo pod data/. Cała analiza w analysis/, jedna komenda i ktokolwiek odtwarza wyniki od raw data.

Licencje: CC0 dla danych, MIT dla kodu, CC-BY dla tekstów. Bierz, kopiuj, replikuj na swoim frameworku. Wymóg jedyny: jeśli cytujesz, podaj źródło.

To nie jest ozdoba. To jest część konstrukcji. W normalnej „wewnętrznej replikacji ustaleń Vercela” wynik wisi w PDF agencyjnym i nikt nie może go zweryfikować. Tutaj każda liczba na dashboardzie ma raw row w SQLite, każdy raw row ma timestamp i fingerprint, każdy fingerprint da się prześledzić do konkretnego requestu na konkretnej celli. Jeśli ktoś wątpi w którąś metrykę, niech otworzy data/ i zobaczy skąd.

Czego dashboard nie pokazuje (jeszcze)

Stan obecny: tracker truncated dzisiaj, ze stu jeden hitów żadnego trafienia z Googlebota czy Claude’a, dominuje GPTBot. Trzy klasy botów łącznie. Layer 3 jeszcze nie chodzi, więc kolumny js_executed_pct na razie nie ma. Hit count to coverage, nie przeżywalność.

To nie jest punkt do publikowania findings. To jest punkt zerowy. Pierwsze findings dataworkowe planuję na czerwiec 2026, po Layer 3 + około tygodniu czystego ruchu na świeżym schemacie.

Co dalej

Plan na najbliższe tygodnie jest dosyć prosty.

Sprint 4 – dorzucam beacon Layer 3 i migrację schematu pod eventy. Po tym dashboard zyskuje kolumnę js_executed_pct per (wzorzec, klasa bota). Wtedy mam właściwy sygnał przeżywalności.

Tydzień czystego ruchu na nowym schemacie. Bez moich smoke testów, bez śmieciowych hits z fazy mode-axis. Tracker był dzisiaj truncated do zera celowo, żeby baseline był czysty.

Pierwszy findings post – oparty na danych – landuje wtedy, kiedy będę miał coś do powiedzenia. Nie wcześniej. Nie ma sensu publikować „wnioski po trzech dniach”, bo zmienność dnia do dnia jest większa niż realne różnice między klasami botów.

Long-term Phase 1 to dwanaście tygodni zbierania. Phase 2, jeśli wynik Phase 1 będzie wart kontynuowania, oznacza dodatkowe frameworki: Nuxt, Astro, SvelteKit, może Solid albo Qwik. Każdy framework dostanie swoją subdomenę testową. Ale o Phase 2 nie mówię, bo Phase 1 dopiero startuje.

Po co to robię

Z perspektywy konsultanckiej: jeśli za pół roku przyjdzie do mnie klient z migracją na Next.js i pytaniem „dlaczego nasze ceny nie są w ChatGPT”, chcę mieć stół pomiarowy, nie intuicję. Powiedzieć mu wprost: „twoja kombinacja js-fetched + click-reveal ma 0% przeżywalności w GPTBocie, ale 73% w ChatGPT-User, czyli pojawi się dopiero, gdy ktoś o twój produkt zapyta wprost”. To jest wartość, której nie kupisz w żadnym narzędziu na rynku.

Z perspektywy branży: ostatnie systematyczne testy JS SEO publikowane są przez Onely i kilku amerykańskich konsultantów. Wszystkie skupiają się na Googlebot. Mapa pattern × bot dla całej ery LLM nie istnieje. Ten projekt ją tworzy.

Z perspektywy osobistej: 25 lat w SEO i to jest pierwszy projekt, w którym zaczynam od pre-rejestracji hipotez. Z kill criterion. Z otwartym datasetem od pierwszego dnia. To jest moja prywatna replika tego, jak prawdziwa nauka powinna wyglądać, w branży, w której badania marketingowe rzadko mają drugą iterację.

Pierwsze findings: maj/czerwiec 2026.
Subskrybcja RSS: jsseo.dev/feed.xml.
Cross-posty na LinkedIn: /in/jakubsawa.
Wszystkie linki, źródła i kod znajdziesz na jsseo.dev.

Infrastruktura jest gotowa. Pomiar zaczyna się dzisiaj. Zobaczymy, co przeżyje.

Podobne wpisy