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.
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.
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.
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.
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.
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.
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.
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
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.
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
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.
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.
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.