Shipping & Infra4 min read

A Korean phone opened devlog in English first

A phone configured for Korean opened dev.ootssu.com in English. The translations existed; the bug lived in the entry route, the document lang attribute, and a mobile-only escape hatch that was hidden. I fixed the Accept-Language parser, rendered lang=ko for Korean pages, and made the language switch visible on mobile.

#i18n#nextjs#locale#mobile#verification
Concept diagram: a Korean phone request sends an Accept-Language header through a locale detector, redirects to /ko, then verifies html lang=ko and a visible mobile KO/EN switch
Language bugs show up in routing, document metadata, and the user's escape hatch before they show up in translation files.

A phone configured for Korean opened dev.ootssu.com in English first. The Korean pages existed. Opening /ko directly showed Korean content. This was not a translation problem. It was an entry-routing problem: where does the first request land?

There was a second failure hiding behind it. On mobile, the language switch was invisible. If the first page landed in the wrong language, the user had no obvious way to recover. A routing bug had turned into a mobile UX bug.

Three layers of failure

The first layer was the root redirect. The old code only checked whether ko appeared anywhere in the Accept-Language string.

const prefersKo = accept
  .split(",")
  .some((l) => l.trim().toLowerCase().startsWith("ko"));
redirect(prefersKo ? "/ko" : "/en");

That is simple, but it is not a real negotiation. Accept-Language has order and q weights. A browser can send en-US,en;q=0.9,ko;q=0.8, where English is preferred. A Korean phone usually sends something closer to ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7. The correct choice is not "does this string contain ko?" It is which supported locale has the highest q/order priority?

The second layer was the document itself. Even when /ko rendered Korean content, the root layout still said this:

<html lang="en" suppressHydrationWarning>

So the page looked Korean but claimed to be English. That matters for browser translation, assistive technology, and search engines. Locale routing is not complete until the document language follows the route.

The third layer was the mobile header. The language link used hidden ... sm:inline-flex, so it disappeared on small screens. Desktop had a recovery path. The phone that exposed the bug did not.

The fix

I pulled locale detection into a small parser. It keeps only supported locales, parses q, sorts by descending weight and original order, then falls back to the site's default locale. For this site, the default is now Korean.

export function detectLocaleFromAcceptLanguage(header: string | null): Locale {
  if (!header) return site.defaultLocale;
  // split ranges, keep supported locales, sort by q desc then order asc
  return candidates[0]?.locale ?? site.defaultLocale;
}

The root route now redirects to /${detectLocaleFromAcceptLanguage(accept)}. In this Next 16 build, the routing hook belongs in proxy.ts, so the proxy also passes the path locale into the root layout with x-devlog-locale. The layout uses that value for <html lang={locale}> and for the skip link text.

Finally, the mobile language switch no longer hides below sm.

className="inline-flex border border-line px-2.5 py-1.5 ..."

That small UI change matters. Automatic locale detection can be wrong. The user still needs a visible way out.

What I verified

After deploying, I checked the behavior through headers and through rendered HTML.

curl -I -H 'Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7' https://dev.ootssu.com/
# location: /ko
 
curl -I -H 'Accept-Language: en-US,en;q=0.9,ko;q=0.8' https://dev.ootssu.com/
# location: /en
 
curl -I https://dev.ootssu.com/
# location: /ko

Then I checked the first line of each page. /ko rendered <html lang="ko">; /en rendered <html lang="en">. The mobile language link also rendered as inline-flex, so the switch is visible on small screens.

The lesson

i18n is not the number of translated files. It is the first request, the document language, and the escape hatch that lets a user correct the site.

The root / route deserves its own test. A working /ko page and a Korean user reaching /ko from / are different things. This is the same family of problem as the dead localized links I found earlier: a multilingual site is only working when users can actually arrive at the right language.

My checklist now has three questions.

  1. Did I test Accept-Language with q weights and order?
  2. Does each locale page render the matching <html lang>?
  3. Is the language switch visible on the mobile first screen?

This passed the publication bar for today's work: real user impact, reproducible cause, small code fix, and a concrete verification path. The AdSense and Cloudflare work did not pass that bar yet because it is still unresolved. I would rather publish the finished operational lesson than narrate an open incident as if it were done.

Related