Every page on this site lives under /ko/ or /en/. The locale is baked into the path, so the URL alone determines the language. Good for sharing, good for indexing.

But people type the domain. From a business card, from an app store listing, from memory — and ceanlab.com carries no language information at all.

1. What Belongs at the Root

Three options were on the table.

A. a language picker at the root → demands one click from every single visitor B. real content at the root, in one language → /ko/ and / hold the same thing = duplicate URLs C. the root as a routing corridor only → no content, just a decision and a redirect

We chose C. Option A asks everyone a question already answered nine times out of ten, and option B puts identical content on two URLs, which means permanently managing which of them is the original.

With C the root becomes infrastructure rather than content. Nobody stays there, and search results show /ko/ or /en/ instead of the root.

2. Which Signal Decides the Language

Several signals are available, and each tells you something different.

country (IP) where you are right now browser language what language your device runs in Accept-Language the HTTP header form of the same explicit (?lang=) what you actually told us

These get conflated, but country and language are separate axes. English speakers in Korea and Korean speakers abroad are both common. Neither signal is accurate alone.

The Four Signals Side by Side

Each one tells you something different, fails in a different way, and — decisively — becomes available at a different moment. That last column is what sets the order of the layers.

Signal Tells you Wrong when Available at x-vercel-ip-country where you are now travel, VPN, Koreans living abroad edge (immediately) Accept-Language device language pref a Korean speaker on an English OS server (on headers) navigator.language the JS form of same same as above browser (after script runs) ?lang= query what you stated never anywhere

The rightmost column decides the layer order outright. The country header is known before a single byte of HTML is sent; navigator.language is known only after the HTML lands and a script runs. Ranked purely on accuracy the order would be reversed — but from the visitor's point of view, the fast signal has to go first.

The same table explains why Accept-Language goes unused. It's available at the same moment as the country header, but this site is static hosting with no server logic to branch on a header. Country routing is expressible in the deploy config; reading the language header would require adding an edge function — too much machinery for a site with two locales.

This site reads country at the edge and language in the browser — in that order, for a reason.

layer 1 — deploy config (at the edge, immediately) x-vercel-ip-country: KR → /ko/ anything else → /en/

The edge decision's advantage is that it completes before any page is downloaded. Reading browser language requires the HTML to arrive and a script to run, and during that window the visitor sees a blank screen or a page that flashes and vanishes. The country check has no such window.

Its disadvantage is accuracy — which is why more layers sit behind it.

Fast-but-rough detection goes first; accurate-but-slow goes behind it.

Most visitors finish at the first layer, and you only pay for the later ones when the first got it wrong.

3. So Why Does a Fallback Page Exist?

Something looks odd here. If the edge redirects everyone, src/index.html is never seen — so why does it exist?

Because the redirect doesn't always apply. Preview environments without the deploy config, requests carrying no country header, someone opening the file directly. In those cases the root is served as the static file itself.

So the remaining layers live inside that file.

layer 2 — JavaScript ?lang=ko / ?lang=en → an explicit choice wins outright navigator.language → anything not starting with "ko" → en layer 3 — noscript <meta http-equiv="refresh" content="0;url=/ko/"> layer 4 — the human "한국어 메인" · "English Main" buttons

Checking ?lang= first in layer 2 matters. What a visitor states outranks every inference. It's also what gives you a way to route around a wrong guess — you can hand an English speaker in Korea the link ceanlab.com/?lang=en.

Layer 4 looks like the one that never runs, but it's the actual floor. With scripts blocked and meta refresh ignored, the visitor still sees two links they can click. Total detection failure never becomes a dead end.

What Each Layer Actually Catches

"Stacking layers" should mean each layer owns one specific failure mode of the layer above it. Otherwise you've just written the same thing four times.

Layer Mechanism What the layer above missed Where this one also fails 1 edge country header (the first layer) preview deploys, no country header 2 ?lang= -> nav.language environments with no config JS blocked, script error 3 meta refresh browsers with JS off meta refresh ignored 4 two links everything above nothing - a human clicks

Read it vertically and each cell is the reason the next row exists. Layer 1's failure condition is layer 2's job; layer 2's failure condition is layer 3's job. A layer that doesn't chain this way doesn't need to be there.

In practice the one you actually hit is layer 1's failure mode: preview deploys. When redirect rules are attached only to the production domain, the root of a preview URL serves the static file as-is. Without layer 2 that means a blank root that only breaks on previews — usually discovered right before a release.

4. Don't Let the Redirect Get Cached

The single most important setting on the root redirect is that it's temporary (302), not permanent.

With a 301 the browser remembers the answer and the decision stops running altogether. A browser that once arrived from Korea keeps landing on /ko/ abroad, in any language. A redirect whose answer must be recomputed cannot be cached — covered in more detail in designing URL migrations.

For the same reason, layer 2's JavaScript uses location.replace(). Navigating via href leaves the root in history, so pressing back returns to the root and immediately bounces forward again. To the visitor, the back button simply appears broken.

The Real Trap in a 301 Is That You Can't Take It Back

301 (permanent) 302 (temporary) Browser cache kept indefinitely re-decided per request Detection re-runs no yes Revisiting after travel frozen on the old answer updates to where you are Routing around via ?lang= can't even reach the root works normally Search engine reading the original moved this is a temporary hint If you ship it wrongly unfixable from the server fixed on the next request

The last row is the one that matters. Once a 301 goes out, browsers that received it don't ask again — even after you fix the server. Until the cache expires or the visitor clears it themselves, the root of your site is permanently pinned to one language in that browser. There is nothing you can do from your side.

Every redirect whose answer can differ between requests has this property: locale routing, A/B tests, branching on login state. Conversely, when an address genuinely moved, 301 is correct — that judgment call is laid out in designing URL migrations.

One question separates them: "could the same browser hitting the same URL tomorrow need to go somewhere else?" If yes, it's a 302.

5. SEO for a Page Nobody Sees

Visitors barely see the root page, but crawlers can. So its <head> carries three things.

<meta name="robots" content="noindex, follow"> <link rel="canonical" href="https://ceanlab.com/ko/"> <link rel="alternate" hreflang="ko" href="https://ceanlab.com/ko/"> <link rel="alternate" hreflang="en" href="https://ceanlab.com/en/"> <link rel="alternate" hreflang="x-default" href="https://ceanlab.com/ko/">

noindex, follow means don't index this URL but do follow its links. A root that surfaces in search results gives users a click followed by yet another redirect, and adds a URL that duplicates /ko/.

The canonical is there anyway to tell any crawler that ignores or hasn't yet processed the noindex one more time where the original lives. Overlapping signals cost nothing.

x-default is the confusing member of the hreflang family: it means "send anyone who doesn't match a listed locale here." Which is precisely the root's job — yet this site points x-default at /ko/ rather than the root, because advertising a noindex URL as your default contradicts itself.

Swap the Combination and the Choice Explains Itself

Root's combination Result noindex + canonical=/ko/ + x-default=/ko/ chosen. all three signals point at /ko/ index + canonical=self + x-default=root root surfaces in search -> click, then redirect noindex + x-default=root offering a no-index URL as the default - contradictory canonical only (no noindex) canonical is a hint and can be ignored -> root indexed nothing at all root and /ko/ compete as duplicate content

The second row is the common mistake. Leave the root indexable and anyone who clicks it from search results eats a second redirect. Worse, from a crawler's perspective / and /ko/ are two URLs with identical content, and it decides on its own which one to rank.

The third row simply doesn't cohere. x-default declares "this is the default entry point" while noindex on the same page says "don't index this." Which one a crawler honors isn't guaranteed.

These three tags also have to stay paired with the locale pages themselves. /ko/ and /en/ must point at each other via hreflang for the root's declaration to mean anything. That pairing breaks the moment a page exists in only one locale — which is why this site makes the build refuse to produce it.

6. What Has to Be Checked by Hand

Much of this cannot be verified locally. The country header only exists in a deployed environment, browser language requires changing browser settings, and noscript requires disabling scripts.

checklist: / → did it land on a locale path? /?lang=en → does it force en regardless of country? back button → does it return and bounce again? scripts off → does it still move, or at least show links?

The third breaks most often. And since nothing errors when it does, you won't know until you press the button yourself.

Pinning the Checklist to Actual Commands

A bare list guarantees you'll skip an item eventually. Fix it as a sequence to run once after each deploy.

1. Status code and destination curl -sI https://ceanlab.com/ | grep -iE '^(HTTP|location|cache-control)' expect: 302, a locale path in location, no long-lived cache directive red flag: a 301 here - fix immediately (browsers that got it can't be fixed) 2. Does an explicit choice beat detection? in a browser, /?lang=en -> /en/ regardless of country in a browser, /?lang=ko -> /ko/ regardless of country 3. Back button / -> locale page -> press back expect: no return-and-bounce (the root isn't in history) 4. With scripts disabled turn JS off in devtools -> load the root expect: meta refresh moves you, or at minimum two links render 5. What a crawler sees curl -s https://ceanlab.com/ | grep -E 'robots|canonical|hreflang' expect: noindex, canonical, and hreflang all present and mutually consistent

Steps 1 and 5 can be automated; 2, 3, and 4 need a human in a browser. Step 4 in particular gets deferred forever on the grounds that few real users have JS off — but checking takes thirty seconds and not noticing takes months.

This verification has the same shape as the app deep link fallback. Both are code that only runs when the happy path failed, so it looks fine day to day and turns out to be broken precisely when it's needed. There's no way to check such code except by deliberately manufacturing the failure.

7. Summary

  • Keep the root a corridor, not content. Real content there brings duplicate-URL management with it.
  • Country and language are different signals. Neither is accurate alone.
  • Fast detection first, accurate detection behind. Most visitors finish at layer one.
  • What the visitor states beats every inference. ?lang= is that channel.
  • Never let the root redirect cache. 302 and location.replace().
  • Keep canonical and noindex on a page nobody sees. Crawlers still can.
  • Each layer must own one failure mode of the layer above. A layer that doesn't is just repetition.
  • A 301 can't be taken back. Ship it wrongly and the browsers that received it are beyond your reach.

Locale detection turned out not to be a problem of guessing right but a problem of designing for guessing wrong. Whatever signal you use, some percentage lands in the wrong place. What matters is whether those people can reach the right one in a single click — and that's a completely different piece of work from accuracy.

Frequently Asked Questions

What if I'm not on Vercel?

Only the header name changes; the structure holds. Cloudflare gives you CF-IPCountry, Netlify exposes context.geo.country.code in edge functions, AWS CloudFront sends CloudFront-Viewer-Country. On static hosting that provides no such header at all (GitHub Pages, for instance), drop layer 1 entirely and start at layer 2. You get one flash at the root and every other layer behaves the same. Splitting the design into layers is exactly what makes that port cheap.

Wouldn't reading Accept-Language at the edge be more accurate than country?

It would. But declarative redirect rules on static hosting generally branch on the country header and stop there; parsing the language header means adding an edge function. That puts server code back into a static site — one more deploy target, one more place to read logs, cold starts to think about. With two locales and a layer 2 that already reads navigator.language, there isn't much to buy. At four or five locales the math changes.

Why not just serve /ko/ at the root and give English its own path?

It works. It also makes the two languages asymmetric. Korean would own two addresses (/ and /ko/) while English owns one, so canonical targets, shareable links, and where the language switcher points all have to be handled differently per language. Add a third locale later and that asymmetry is the bill. Keeping every language the same shape from the start is cheaper.

Doesn't a 302 mean a redirect on every single request?

Not in any way you'd feel. The decision resolves at the edge from a header alone, never reaching an origin server — single-digit milliseconds in practice. And only requests to the root pay it. Traffic from search, shares, and bookmarks already points at /ko/ or /en/ and skips the redirect entirely. The root is a first-visit corridor, not a routine path.

Wouldn't storing the chosen language in a cookie be better?

For user experience, yes. It also stops being a static site. Branching on a cookie makes the response vary per request, which means you can no longer serve it straight from CDN cache. You end up adding the cookie to the cache key or introducing an edge function, and from that point it's "a site computed per request" rather than "static files deployed." This site declined that trade and left a ?lang= link people can bookmark instead. The always-visible language switcher exists for the same reason.

Does this structure survive three or more locales?

The layering does. What changes is that each layer's branch becomes a lookup instead of a binary. Two things grow alongside it, though: the country-to-locale mapping table in the deploy config gets long, and every page carries n hreflang tags. The second is the bigger nuisance — the fix is to stop hand-writing those tags and generate them in one layout by iterating a locale list, so adding a locale becomes one line in an array. Same reasoning as not hand-writing the sitemap.