Przejdź do treści
Artur Shaposhnyk
Studium przypadku

obdukcje.wroclaw.pl

Strona gabinetu medycyny sądowej we Wrocławiu, przebudowana tak, żeby każdy z czterech języków miał własną, prawdziwą stronę, a nie jedną stronę, która podmienia swój tekst.

Rola
Projekt, budowa i wdrożenie
Rok
2026
Stack
JavaScript · Wielojęzyczność · SEO · Vercel
01

Kontekst

Marcin Zawadzki jest specjalistą medycyny sądowej we Wrocławiu. Jest profesorem Politechniki Wrocławskiej, kieruje tam jednostką i wykonuje sekcje sądowo-lekarskie w Wojewódzkim Szpitalu Specjalistycznym im. J. Gromkowskiego. Równolegle prowadzi prywatny gabinet, w którym wykonuje obdukcje — badanie sądowo-lekarskie po pobiciu albo wypadku, w którym obrażenia są dokumentowane w formie, która obroni się przed policją, prokuratorem lub sądem.

Ludzie, którzy jej potrzebują, rzadko są w stanie porównywać oferty, a spora część z nich nie czyta po polsku. Wrocław ma dużą społeczność ukraińsko- i rosyjskojęzyczną, a pacjentów z Norwegii przyjmuje on w ramach odrębnej działalności. Strona musiała więc od początku działać w czterech językach — i w pierwszej wersji, którą zbudowałem, działała, tyle że w sposób, który, jak się okazało, się nie liczy.

02

Ograniczenia

0
różnych pikseli — 1440×900 i 390×844

Ograniczenia w większości narzuciłem sobie sam — to uczciwa wersja. Tę stronę już raz wdrożyłem. Cokolwiek zmieniałem, musiało zostawić stronę, którą widzą jego pacjenci, dokładnie taką, jaka była, nie mogło wprowadzić niczego, co musiałby utrzymywać, i musiało przetrwać miesiące, w których jej nie dotknę.

Polska strona nie mogła się zmienić
Zaakceptował tę stronę, kierował na nią ludzi i miał ją wydrukowaną na wizytówce. Redesign nie wchodził w grę i niczego by nie poprawił. Wszystkie zmiany w strukturze musiały się odbyć pod niezmienionym wyglądem, dlatego testem akceptacyjnym było porównanie pikseli, a nie spotkanie z klientem.
Żadnego kroku budowania, który musiałby sam uruchamiać
Ręcznie pisany HTML, CSS i JavaScript, wdrażany zwykłym pushem. Framework oznaczałby przekazanie mu projektu, którego nie umiałby otworzyć — i byłby przesadą jak na witrynę z czterech stron.
Rezerwacja musiała działać bez backendu
Gabinet potrzebował rezerwacji online, a statyczna strona nie ma gdzie trzymać dostępności terminów, maili z potwierdzeniem ani kalendarza. Zostaje hostowany widżet, a hostowany widżet to fragment strony, który nie przyjmuje poleceń. Cal.com wybiera język na podstawie przeglądarki odwiedzającego i nie daje sposobu, żeby to nadpisać — wypróbowałem udokumentowane parametry i są ignorowane. Tak więc jedyna interaktywna rzecz na czterojęzycznej stronie jest jedyną, która ignoruje, w jakim języku czytasz.
03

Kluczowe decyzje

4
indeksowalne adresy URL tam, gdzie był jeden

Gdy zaczynałem, otwarte były trzy pytania i tylko pierwsze naprawdę dotyczyło tłumaczenia.

Prerenderować cztery dokumenty, zamiast naprawiać podmianę w czasie działania
Alternatywą było zostawić słownik po stronie klienta i sprawić, żeby działał poprawnie: czytać nagłówek Accept-Language, ustawiać hreflang, zmieniać URL przez History API. To więcej kodu, a crawler nadal musiałby poprawnie wykonać JavaScript, żeby zobaczyć treść. Wygenerowanie czterech plików w czasie budowania ze słownika, który już miałem, wymagało mniej kodu i całkowicie usuwało ten problem. Słownik się nie zmienił — zmieniło się tylko to, kiedy działa.
Zostawić kod pisany ręcznie, zamiast przechodzić na framework
Next.js dałby mi cztery trasy za darmo. Dałby też witrynie folder node_modules, build, który trzeba utrzymywać na zielono, i ścieżkę aktualizacji — w projekcie, który ma cztery strony i jeden formularz. Framework wziąłem do własnej strony, którą sam utrzymuję, a tę zostawiłem jako pliki, które on w zasadzie mógłby otworzyć w edytorze.
Zatrzymać build, zamiast wracać do fallbacku
Łagodna opcja to wrócić do polskiego, gdy brakuje klucza — i tak robiła pierwsza wersja. Tak też witryna po cichu staje się przetłumaczona w połowie. Gdy brakujący klucz jest błędem builda, awaria zdarza się na mojej maszynie, kiedy na nią patrzę, a nie na jego stronie, kiedy nikt nie patrzy.
04

W szczegółach

−78,3%
JavaScript klienta — z 20 942 do 4544 bajtów

Wszystko poniżej to jeden problem widziany z pięciu stron: witryna była zbudowana tak, że jej treść istniała dopiero po uruchomieniu JavaScriptu, a wyszukiwarka jest tym jednym odwiedzającym, który na to nie czeka.

Hierarchia jak na triażu. Cena, adres, języki i wymóg dokumentu tożsamości są w bloku faktów tuż pod hero, przed jakąkolwiek perswazją — ten odwiedzający został pobity albo ranny kilka godzin temu i potrzebuje jednej odpowiedzi, a nie broszury. Numer telefonu powtarza się w nagłówku, w hero, w sekcji kontaktowej i w stopce.

Cztery indeksowalne dokumenty. Polski, angielski, rosyjski i norweski są prerenderowane w czasie budowania z jednego szablonu i jednego słownika, każdy pod własnym adresem URL, z wzajemnym hreflang. Zastąpiło to przełącznik języka po stronie klienta, który zostawiał trzy z czterech języków niewidoczne dla wyszukiwarki.

Rezerwacja ładowana później. Działa na osadzonym kalendarzu Cal.com, a nie na własnym rozwiązaniu, i uruchamia się dopiero wtedy, gdy odwiedzający się do niej zbliża — ktoś, kto potrzebuje tylko numeru telefonu, nie ponosi jej kosztu, a numer telefonu pozostaje widoczny tuż pod nią.

Kwalifikacje jako dane strukturalne. Wynikiem badania jest dokument mający moc prawną przed policją i prokuraturą, więc tytuły specjalisty, adres, telefon, przedział cenowy i języki pracy są oznaczone jako Physician i MedicalBusiness — podane czytelnikom, czytelne dla wyszukiwarek.

Pisane ręcznie i statyczne. Bez frameworka i bez CMS-a — zwykły HTML, CSS i JavaScript, z krokiem budowania, który nie robi nic poza prerenderowaniem tekstu. Odwiedzający korzysta z mobilnego internetu przed szpitalem, gdzie każda sekunda wczytywania to ludzie, którzy rezygnują.

Anatomia

  1. 01 / 19

    Cztery prawdziwe adresy URL

    Przełącznik stał się <a href>: / · /en/ · /ru/ · /no/. Wzajemne hreflang na każdej stronie, x-default na polskim adresie głównym.

    Zapisywałem wybór w localStorage, więc przełącznik działał dopiero wtedy, gdy ktoś już znalazł stronę i coś kliknął. Każdy, kto trafiał na nią pierwszy raz z norweskiego wyniku wyszukiwania, lądował na polskiej wersji i nie miał powodu przypuszczać, że istnieje inna.

  2. 02 / 19

    Jeden słownik, cztery dokumenty

    Każda strona jest prerenderowana podczas budowania z tego samego słownika I18N.

    W pierwszym odruchu chciałem naprawić podmianę. To sprawiłoby tylko, że zła rzecz działałaby lepiej — słownik był w porządku, problem polegał na tym, że crawler nigdy go nie uruchamia. Te same dane dają teraz cztery dokumenty w czasie budowania zamiast jednego dokumentu i funkcji.

  3. 03 / 19

    Wizualnie nic się nie zmieniło

    Polska strona różni się od poprzedniej wersji o 0 pikseli przy 1440×900 i 390×844.

    Zrzuty ekranu starej i nowej polskiej strony w obu rozdzielczościach, porównane piksel po pikselu. Hreflang jest dla klienta niewidoczny; strona, którą widzą jego pacjenci — nie. To, że mogłem powiedzieć, że nic się w niej nie zmieniło, sprawiło, że na resztę łatwo było się zgodzić.

  4. 04 / 19

    Co kiedyś dostawał crawler

    Jeden adres URL obsługiwał wszystkie cztery języki. Słownik podmieniał tekst po stronie klienta i zapamiętywał wybór w localStorage.

    Jeden adres URL, po polsku, dla wszystkich czterech języków. Trzy z nich z punktu widzenia wyszukiwarki nie istniały — nie chodzi o słabą pozycję: nie było ich wcale. Nie było adresu, któremu można by nadać pozycję.

  5. 05 / 19

    Tekst istnieje, zanim ruszy JavaScript

    Nic nie jest podmieniane w czasie działania; dokument przychodzi kompletny.

    Strona nie pokazuje już przez chwilę polskiego tekstu przed przełączeniem. Tekst jest w dokumencie, kiedy ten przychodzi, więc nie ma czego podmieniać i nie ma na co czekać.

  6. 06 / 19

    Build, który nie pozwala na kłamstwo

    Build celowo kończy się błędem, gdy brakuje klucza tłumaczenia albo wymaganego zasobu.

    W pierwszej wersji brakujący klucz bez słowa wracał do polskiego, co wygląda jak gotowa strona w języku, którego nikt nie wybrał. Teraz build się zatrzymuje. Mnie kosztuje to minutę; cichy fallback tkwiłby tam, dopóki ktoś czytający po norwesku przypadkiem by nie spojrzał.

  7. 07 / 19

    Szersze dane strukturalne

    JSON-LD @type zmienił się z Physician na ["Physician","MedicalBusiness"], doszły availableLanguage, inLanguage i jobTitle.

    Physician opisuje osobę. MedicalBusiness opisuje miejsce z adresem, godzinami otwarcia i ceną — a właśnie tego potrzebuje ktoś, kto szuka badania we Wrocławiu. Gabinet jest jednym i drugim, więc teraz mówi o sobie jedno i drugie.

  8. 08 / 19

    Języki zadeklarowane dwa razy, celowo

    Te same cztery języki są w widocznej karcie i w availableLanguage.

    Karta jest dla odwiedzającego, a availableLanguage dla crawlera. Mówią to samo, a pilnowanie ich zgodności to cena za to, żeby oba były prawdziwe.

  9. 09 / 19

    Spójność zamiast rozjazdu tłumaczeń

    Brakujący klucz to błąd builda, a nie cichy fallback do angielskiego.

  10. 10 / 19

    Zasoby są wymagane, nie opcjonalne

    Brakujący obraz zatrzymuje build tak samo jak brakujący tekst.

    Nauczył mnie tego plik weryfikacyjny Search Console. Został zapisany pod złą nazwą i po cichu zwracał 404 — błąd z rodzaju tych, których nic nie ujawnia, dopóki coś z zewnątrz nie spróbuje pobrać pliku. Teraz build nie kończy się bez niego.

  11. 11 / 19

    JavaScript w przeglądarce — po zmianie

    main.js: 20 942 → 4544 bajty (−78,3%).

    Słownik, który napisałem, i kod, który stosował go przy każdym wczytaniu strony. Zostało to, co jest interakcją, a nie treścią.

  12. 12 / 19

    Bez warstwy tłumaczeń w czasie działania

    Słownik i jego pamięć w localStorage zniknęły z klienta całkowicie.

    Przy pierwszym renderowaniu przeglądarka czytała localStorage, wybierała język, przechodziła po DOM i podmieniała w nim tekst — pracę, którą jej zadałem, a której nigdy nie powinna była wykonywać. Teraz po prostu wyświetla to, co dostała.

  13. 13 / 19

    Lighthouse ani drgnął

    99 / 100 / 100 / 100 przed i po — wynik już wcześniej był na maksimum.

    Już wcześniej było 99 / 100 / 100 / 100. Lighthouse mierzy, jak szybko strona przychodzi, a nie to, czy przychodzi właściwa strona — trzy z czterech języków były niewidoczne dla wyszukiwarki i żaden wynik wydajności nigdy by tego nie pokazał.

  14. 14 / 19

    Sygnały dla wyszukiwania lokalnego

    Adres, telefon i dane gabinetu zasilają typ MedicalBusiness.

    Adres, języki i cena są teraz czytelne dla maszyn, a nie tylko dla ludzi. Czy zmieni to cokolwiek w wyszukiwaniu lokalnym — tego nie mogę jeszcze twierdzić: o indeksowanie poprosiłem w sierpniu i obserwuję Search Console, zamiast zgadywać.

  15. 15 / 19

    Jedna forma kanoniczna

    trailingSlash: true w vercel.json; adresy kanoniczne wskazują URL z ukośnikiem.

    Bez tego /en i /en/ to dwa adresy URL serwujące jedną stronę, a każdy link, który wybiera drugą pisownię, dzieli sygnały między nie. Teraz jedna forma jest kanoniczna, a druga na nią przekierowuje.

  16. 16 / 19

    Zewnętrzny widżet ładowany na żądanie

    Cal.com inicjalizuje się przez IntersectionObserver, a nie przy wczytaniu strony.

    Dzięki temu cudzy kod nie spowalnia pierwszego wyświetlenia strony, którą większość odwiedzających czyta, niczego nie rezerwując. Ceną jest to, że widżet nie jest od razu gotowy dla tych, którzy rezerwują — wczytuje się, gdy do niego docierają.

  17. 17 / 19

    Jedyna część poza moją kontrolą

    Widżet rezerwacji bierze język z przeglądarki odwiedzającego. Cal.com nie pozwala tego zmienić — to wciąż otwarta prośba o nową funkcję.

    Alternatywą jest rezygnacja z widżetu i zbudowanie formularza na ich API, czyli wzięcie na siebie dostępności terminów, stref czasowych i maili z potwierdzeniem. Dla gabinetu z kilkoma terminami dziennie to zły interes, więc widżet zostaje, a klient wie, dlaczego język podąża za przeglądarką odwiedzającego.

  18. 18 / 19

    sitemap.xml i robots.txt

    Oba generuje build i oba zostały zgłoszone w Search Console: Success, 4 strony.

    Oba powstają z tej samej listy tras, którą build i tak już ma, więc nie mogą rozjechać się z tym, co faktycznie zostało wdrożone. Zgłoszone w Search Console: cztery strony, przyjęte.

  19. 19 / 19

    Plik weryfikacyjny, który zwracał 404

    googlef6e6b7cd3c7e53af.html został zapisany pod złą nazwą i zwracał 404. Teraz jest wymaganym zasobem builda.

Osiem momentów z przebudowy, w kolejności, w jakiej wczytuje je strona. Rysunek to prawdziwa strona pocięta na części — każda pozycja jest zmierzona na zrzucie działającej strony, a nie narysowana od nowa.
05

Wyniki

99
Lighthouse na stronie klienta, przed i po
4
strony przyjęte — Search Console

Co zostało wdrożone: cztery indeksowalne strony tam, gdzie była jedna, wzajemne hreflang, dane strukturalne opisujące gabinet jako miejsce, a nie tylko osobę, oraz bundle klienta mniejszy o 78%. Polska strona — ta, na którą gabinet faktycznie kieruje ludzi — wygląda dokładnie tak jak wcześniej, co było warunkiem wszystkich tych zmian.

Search Console, 22 sierpnia 2026: usługa zawiera cztery adresy URL i wszystkie cztery są zaindeksowane. Trzy strony językowe przeszły z nieznanych do zaindeksowanych 11 sierpnia, trzy dni przed tym, jak ręcznie poprosiłem o indeksowanie — mapa witryny i zestaw hreflang wykonały tę pracę same. Jedyny adres, który Google pokazuje jako niezaindeksowany, to domena bez www, która przekierowuje 301 na www — czyli przekierowanie działa tak, jak powinno.

Dwadzieścia osiem dni do 22 sierpnia: polska strona główna zebrała 20 kliknięć z 415 wyświetleń przy średniej pozycji 5,9. Angielska miała 10 wyświetleń na pozycji 28,6, rosyjska 9 na 14,2, norweska 9 na 23,8. W żadną z tych trzech nikt nie kliknął.

Jedenaście dni historii indeksu to pozycja wyjściowa, a nie wynik. Pozycje w okolicach dwudziestej to miejsce, w którym siedzi adres bez historii, a pytanie, na którym naprawdę mi zależy — czy ktoś we Wrocławiu, kto nie czyta po polsku, znajdzie tę stronę i zarezerwuje wizytę — nie miało jeszcze czasu dostać odpowiedzi. W całym kwartale 64 z około 2535 wyświetleń pochodziły spoza Polski, a jedno z nich zamieniło się w kliknięcie.

To, co już działało, działało dalej: średnia pozycja 5,7 dla "obdukcja wrocław" przy 216 wyświetleniach i 5,0 dla "obdukcja wrocław prywatnie". Osiemdziesiąt jeden procent wyświetleń pochodzi z urządzeń mobilnych, dlatego layout narysowałem najpierw w szerokości 360 px.

Sprawdzam to co miesiąc i przepisuję ten akapit, gdy liczby się zmieniają.

06

Co zrobiłbym inaczej

Za pierwszym razem wdrożyłbym prawdziwe adresy URL. Nie dlatego, że nie wiedziałem, że przełączanie po stronie klienta jest gorsze dla wyszukiwarek — zapytany, powiedziałbym to — ale dlatego, że nigdy o to nie zapytałem. Strona działała, kiedy ją testowałem: w przeglądarce, w języku, który sam wybrałem przełącznikiem. Nie sprawdziłem, jak wygląda dla czegoś, co nie klika.

Nawyk, który z tego wyniosłem: zanim uznam stronę za gotową, patrzę na nią tak, jak patrzy crawler. Podgląd źródła, wyłączony JavaScript — i sprawdzenie, czy treść nadal tam jest.