Skip to content
Artur Shaposhnyk
Case study

obdukcje.wroclaw.pl

A forensic medicine practice in Wrocław, rebuilt so that all four of its languages exist as real pages instead of one page that swapped its own text.

Role
Design, build, and deploy
Year
2026
Stack
JavaScript · Multilingual · SEO · Vercel
01

Context

Marcin Zawadzki is a forensic medicine specialist in Wrocław. He is a professor at the Wrocław University of Science and Technology, heads a department there, and performs forensic autopsies at the J. Gromkowski Provincial Specialist Hospital. Alongside that he runs a private practice doing obdukcje — the forensic examination someone has after an assault or an accident, where injuries are documented in a form that holds up in front of the police, a prosecutor or a court.

The people who need one are rarely in a state to shop around, and a good share of them do not read Polish. Wrocław has a large Ukrainian and Russian-speaking population, and he sees Norwegian patients through a separate line of work. So the site had to work in four languages from the start — and in the first version I built, it did, in the way that turned out not to count.

02

Constraints

0
different pixels — 1440×900 and 390×844

The constraints were mostly self-imposed, which is the honest version. I had already shipped this site once. Whatever I changed had to leave the page his patients see exactly as it was, could not introduce anything he would have to maintain, and had to survive me not touching it again for months.

The Polish page could not change
He had approved that page, sent people to it, and had it printed on a card. A redesign was not on the table and would not have helped anything. Everything structural had to happen behind an identical rendering, which is why the acceptance test was a pixel comparison rather than a review call.
No build step he would ever have to run
Hand-written HTML, CSS and JavaScript, deployed from a push. Adding a framework would have meant handing him a project he cannot open, and it would have been a lot of machinery for a site of four pages.
Booking had to work without a backend
The practice needed people to book online, and a static site has nowhere to put availability, confirmation mail or a calendar. That leaves a hosted widget, and a hosted widget is a piece of the page that does not take instructions. Cal.com picks its language from the visitor's browser and offers no way to override it — I tried the documented parameters and they are ignored. So the one interactive thing on a four-language site is the one thing that ignores which language you are reading.
03

Key decisions

4
indexable URLs, where there had been one

Three questions were open when I started, and only the first one was really about translation.

Prerender four documents, rather than repair the runtime swap
The alternative was to keep the client-side dictionary and make it behave: read the Accept-Language header, set hreflang, push a URL with the History API. That is more code, and it still puts a crawler in the position of having to run JavaScript correctly to see the content. Generating four files at build time from the dictionary I already had was less code and removed the question entirely. The dictionary did not change — only when it runs.
Keep it hand-written instead of moving to a framework
Next.js would have given me the four routes for free. It would also have given the site a node_modules folder, a build to keep green and an upgrade path, on a project that has four pages and one form. I took the framework for my own site, where I maintain it, and left this one as files he could in principle open in an editor.
Fail the build rather than fall back
The gentle option is to fall back to Polish when a key is missing, and that is what the first version did. It is also how a site quietly ends up half-translated. Making a missing key a build error means the failure happens on my machine while I am looking at it, instead of on his site while nobody is.
04

Deep dive

−78.3%
client JavaScript — 20,942 to 4,544 bytes

Everything below is one problem seen from five angles: the site was built so that its content only existed after JavaScript ran, and a search engine is the one visitor that does not wait for that.

Triage-first hierarchy. Price, address, languages and the ID requirement sit in a facts block directly under the hero, ahead of any persuasion — this visitor was assaulted or injured hours ago and needs one answer, not a brochure. The phone number repeats in the header, the hero, the contact section and the footer.

Four indexable documents. Polish, English, Russian and Norwegian are prerendered at build time from one template and one dictionary, each at its own URL with reciprocal hreflang. This replaced a client-side language switch that left three of the four languages invisible to search.

Scheduling, deferred. Booking runs on an embedded Cal.com calendar rather than a custom build, and it initialises only as the visitor approaches it — a visitor who just needs the phone number never pays for it, and the phone number stays visible directly beneath it.

Credentials as structured data. The examination produces a document with legal standing before police and prosecutors, so the specialist's titles, address, telephone, price range and working languages are marked up as Physician and MedicalBusiness — stated for readers, readable by search engines.

Hand-written and static. No framework and no CMS — plain HTML, CSS and JavaScript, with a build step that does nothing but prerender text. The visitor is on mobile data outside a hospital, where load time is measured in people who leave.

Anatomy

  1. 01 / 19

    Four real URLs

    The switch became <a href>: / · /en/ · /ru/ · /no/. Reciprocal hreflang on every page, x-default on the Polish root.

    I stored the choice in localStorage, so the switch only worked once you had already found the site and clicked something. Anyone arriving cold from a Norwegian search result landed on Polish with no reason to think there was another version.

  2. 02 / 19

    One dictionary, four documents

    Every page is prerendered at build time from the same I18N dictionary.

    My first instinct was to fix the swap. That would have made the wrong thing work better — the dictionary was fine, the problem was that a crawler never runs it. The same data now produces four documents at build time instead of one document and a function.

  3. 03 / 19

    Nothing moved visually

    The Polish page renders 0 different pixels against the previous version at 1440×900 and 390×844.

    Screenshots of the old and new Polish page at both breakpoints, compared pixel by pixel. Hreflang is invisible to the client; the page his patients see is not. Being able to say that nothing about it changed is what made the rest easy to agree to.

  4. 04 / 19

    What the crawler used to receive

    One URL served all four languages. A dictionary swapped the text on the client and remembered the choice in localStorage.

    One URL, in Polish, for all four languages. Three of them did not exist as far as search was concerned — not ranked badly, absent. There was no URL to rank.

  5. 05 / 19

    Text exists before JavaScript runs

    Nothing is swapped at runtime; the document ships complete.

    The page no longer shows Polish for a moment before switching. The text is in the document when it arrives, so there is nothing to swap and nothing to wait for.

  6. 06 / 19

    A build that refuses to lie

    The build fails on purpose when a translation key or a required asset is missing.

    In the first version a missing key fell back to Polish without saying anything, which looks like a finished page in a language nobody chose. Now the build stops. It costs me a minute; the silent fallback would have sat there until someone who reads Norwegian happened to look.

  7. 07 / 19

    Structured data widened

    JSON-LD @type went from Physician to ["Physician","MedicalBusiness"], plus availableLanguage, inLanguage and jobTitle.

    Physician describes a person. MedicalBusiness describes a place with an address, opening hours and a price — which is what someone searching for an examination in Wrocław is looking for. The practice is both, so it now says both.

  8. 08 / 19

    Languages declared twice, deliberately

    The same four languages appear in the visible card and in availableLanguage.

    The card is for the visitor and availableLanguage is for the crawler. They say the same thing, and keeping them in step is the price of both being true.

  9. 09 / 19

    Parity instead of translation drift

    A missing key is a build error, not a silent fallback to English.

  10. 10 / 19

    Assets are required, not optional

    A missing image fails the build the same way a missing string does.

    The Search Console verification file taught me this. It was saved under the wrong name and quietly returned 404 — the kind of mistake nothing surfaces until something external tries to fetch it. The build now refuses to finish without it.

  11. 11 / 19

    Client JavaScript, after

    main.js: 20,942 → 4,544 bytes (−78.3%).

    The dictionary I had written and the code that applied it on every page load. What is left is interaction rather than content.

  12. 12 / 19

    No runtime translation layer

    The dictionary and its localStorage memory are gone from the client entirely.

    On first paint the browser used to read localStorage, pick a language, walk the DOM and replace the text in it — work I had given it that it should never have had to do. Now it paints what it was sent.

  13. 13 / 19

    Lighthouse did not move

    99 / 100 / 100 / 100 before and after — it was already at the ceiling.

    It was already 99 / 100 / 100 / 100. Lighthouse measures how fast a page arrives, not whether the right page arrives — three of four languages were invisible to search and no performance score was ever going to say so.

  14. 14 / 19

    Local business signals

    Address, phone and practice details feed the MedicalBusiness type.

    The address, the languages and the price are now machine-readable rather than only human-readable. Whether that moves anything in local search is not something I can claim yet — indexing was requested in August and I am watching Search Console rather than guessing.

  15. 15 / 19

    One canonical form

    trailingSlash: true in vercel.json; canonicals point at the slashed URL.

    Without it, /en and /en/ are two URLs serving one page, and every link that picks the other spelling splits the signals between them. One form is canonical now and the other redirects to it.

  16. 16 / 19

    Third-party embed, lazily booted

    Cal.com initialises on IntersectionObserver rather than on page load.

    It keeps someone else's bundle out of the first paint on a page most visitors read without booking anything. The cost is that the widget is not instantly there for the ones who do — it loads as they reach it.

  17. 17 / 19

    The one part outside my control

    The booker takes its language from the visitor’s browser. Cal.com exposes no override — it is an open feature request.

    The alternative is dropping the embed and building the form against their API, which means owning availability, time zones and confirmation mail. For a practice with a handful of slots a day that is not a good trade, so the widget stays and the client knows why the language follows the visitor's browser.

  18. 18 / 19

    sitemap.xml and robots.txt

    Both are generated by the build and were submitted to Search Console: Success, 4 pages.

    Both are generated from the same list of routes the build already has, so they cannot drift from what actually shipped. Submitted to Search Console: four pages, accepted.

  19. 19 / 19

    The verification file that 404ed

    googlef6e6b7cd3c7e53af.html was saved under the wrong name and returned 404. It is now a required build asset.

Eight moments from the rebuild, in the order the page loads them. The figure is the real page, cut into its parts — every position is measured from the live capture, not redrawn.
05

Results

99
Lighthouse on the client site, before and after
4
pages accepted — Search Console

What shipped: four indexable pages where there had been one, reciprocal hreflang, structured data that describes the practice as a place rather than only a person, and a client bundle 78% smaller. The Polish page — the one the practice actually sends people to — renders identically to before, which was the condition for doing any of it.

Search Console, 22 August 2026: the property holds four URLs and all four are indexed. The three language pages went from unknown to indexed on 11 August, three days before I requested indexing by hand — the sitemap and the hreflang set did that work on their own. The one URL Google lists as not indexed is the apex, which 301s to www, and that is the redirect doing its job.

Twenty-eight days to 22 August: the Polish homepage took 20 clicks from 415 impressions at an average position of 5.9. English had 10 impressions at position 28.6, Russian 9 at 14.2, Norwegian 9 at 23.8. None of the three has been clicked.

Eleven days of index history is a starting position, not a result. Positions in the twenties are where a URL with no history sits, and the question I actually care about — whether someone in Wrocław who does not read Polish finds this page and books — has not had time to answer itself. Across the full quarter, 64 of roughly 2,535 impressions came from outside Poland, and one of them converted to a click.

The part that was already working kept working: average position 5.7 for "obdukcja wrocław" across 216 impressions, 5.0 for "obdukcja wrocław prywatnie". Eighty-one percent of impressions are mobile, which is why I drew the layout at 360px before anything else.

I re-check this monthly and rewrite the paragraph when the numbers change.

06

What I'd do differently

I would have shipped real URLs the first time. Not because I did not know that client-side switching is worse for search — I would have said so if asked — but because I never asked. The site worked when I tested it, in a browser, in the language I chose from the switch. I did not check what it looked like to something that does not click.

The habit I took from it: before calling a page done, look at it the way a crawler does. View source, disable JavaScript, and see whether the content is still there.