One language per page: why I dropped IP-country detection and bilingual pages for browser language plus hreflang
Six web services each picked their initial language differently. The one using IP country served English to Googlebot only, so the search-result language could not be controlled; the landing page mixed Korean and English on one page, against Google's "one language per page" expectation. How I unified every site under one rule.
Six sites, six ways to pick a language
Each of the six web services decided its first language differently.
- Online tool directory: the
CF-IPCountryheader Cloudflare adds; Korean if the country was Korea - Portfolio and showcase: Korean if the browser time zone was Seoul
- Error-code lookup: no detection at all, so overseas visitors got Korean
- Root landing: Korean and English side by side on a single page
Each had a reason, and each was a problem.
The problem with IP country
A Korean resident on an English browser gets Korean. That is tolerable. The real problem is Googlebot. It crawls from the US, so it always received the English page. There was no way for a Korean title to appear in Korean search results. The server had no control over which language went into search. I deleted langFromCountry().
Time-zone detection went for the same reason: if the client decides in JS, the first HTML a crawler sees is the pre-decision state.
The problem with bilingual pages
The root landing had a Korean paragraph and an English paragraph next to each other. I suspect it came from misreading an early ad-network rejection as "not enough text" and padding the page. But Google expects one language per page. A mixed page is ambiguous about which language's results it belongs in, and lang can only take one value.
The unified rule
Set as the standard for every site on 11 August 2026, with the root, the last exception, brought in line on 28 August.
- Initial language comes from the browser:
Accept-Languagestarting withkomeans Korean, anything else English. Server-rendered sites read the header; static sites decide on first load in JS; either way, each language has its own URL. - Separate URLs per language.
/is Korean (lang="ko"),/en/is English (lang="en"). The converter site was split into/mdhtml/and/mdhtml/en/too. - Both pages carry
hreflangpointing at each other,x-defaultpoints at Korean, and each has its own self-canonical andog:locale. - A language-switch link in the header, so the user can fix a wrong auto-detection in one tap.
- Both URLs go into the sitemap.
For the root I kept the bilingual file as the source and wrote a script that splits the Korean and English sentences of its 22 cards (16 apps + 6 web services) into two generated pages. The bilingual file survives only as a backup.
Fonts unified the same day
I also collapsed fonts to one. A single dynamic-subset CSS for the Pretendard variable font, and nothing else. Out went Geist on the portfolio, Clash Display on showcase headings, Inter + Space Grotesk on the directory, Roboto + Noto Sans KR on the converter. Different fonts per page and it stops looking like one site.
The exception is the monospace font for code. Pretendard is proportional, so code alignment breaks, and the web SSH client is an xterm.js terminal that falls apart without monospace. JetBrains Mono stays.
Verification
On the live site I fetched each page with curl -H "Accept-Language: en" and ko, checked <html lang> and the language of the first paragraph, confirmed the two pages' hreflang point at each other, and that both are in the sitemap. Search results follow after re-crawl, so that part is a matter of waiting.
Summary
- IP-country detection shows Googlebot one language forever. Drop it.
- One language per page: its own URL,
lang, hreflang and canonical. - Initial language from the browser; switch link in the header.
- One font across the whole site, with monospace for code as the only exception.