Locales, routing, and SEO
How a multilingual site picks a language, gives every language its own URL, tells search engines about the copies with hreflang, renders and caches each locale on the server, keeps translation bytes small, and copes with German text that runs 30% longer.
The problem: one site, many languages, many audiences
Picture a travel booking site that has just launched in French, German, and Japanese. The team translated every string, wired up a translation library, and shipped. A week later, three reports land on the same day.
- Search. Google shows the English page to people searching in French. The French pages barely appear in results at all.
- Caching. A user in Montreal who prefers English got the French home page. Support finds that the CDN, the network of caching servers near users, cached the French HTML and served it to everyone in that region.
- Layout. On the German checkout page, the button that should read
Zahlungspflichtig bestellen(place order with obligation to pay) is cut off in the middle, and the price wraps under it.
None of these is a translation bug. Every string was translated correctly. They are system design bugs: decisions about URLs, detection, rendering, caching, and layout that were never made on purpose. This lesson is about those decisions.
What changes when a site speaks more than one language
Earlier lessons in this module covered how strings are stored and loaded (Translating an app), how dates and numbers change per locale (Dates, numbers, and plurals), and how right-to-left scripts flip the layout (Right-to-left layouts). This lesson covers what sits around them: the parts the whole system has to agree on.
- Detection. When someone arrives, which language do you show, and how do they change it?
- URLs. Does each language have its own address, and what shape does it take:
/fr/,fr.example.com,example.fr, or?lang=fr? - hreflang. How do you tell search engines that five pages are the same content in five languages?
- Rendering and caching. How does the server render the right language, and how does a CDN cache it without mixing languages?
- Bytes. How do you avoid shipping every language's strings, date data, and fonts to every user?
- Layout. How do you design screens that survive text that is 30% longer, or 40% shorter, than English?
First, what is a locale?
A locale is a language plus, optionally, a region and a script, written as a BCP 47 tag, the standard format for language tags. fr is French. fr-CA is French as used in Canada.
zh-Hant-TW is Chinese in Traditional script, as used in Taiwan. The region matters for spelling, currency, and date order; the script matters for which characters and fonts you need.
Language and country are different things, and mixing them up causes many of the bugs in this lesson. Switzerland has four national languages. Many people in the United States read Spanish.
A traveller from Japan opening your site in Paris still reads Japanese. Keep the two apart: language decides the words; country may decide prices, shipping, and legal text.
Check your understanding · Exercise 1
Which of these are system design problems rather than translation problems? Select all that apply.
The mental model: the language is part of the address
Think of an airport bookshop that sells the same novel in ten languages. Each language has its own labelled shelf. A greeter at the door asks travellers which language they read and points them to a shelf, but anyone can walk to any shelf. Inside every copy is a slip that says "also on shelf 4 in German, shelf 7 in French".
A multilingual site works the same way. The locale URL is the shelf: /fr/hotels/paris is the French copy and always will be. Detection is the greeter: it runs once, when someone arrives without a shelf in mind, and it only suggests. hreflang is the slip inside the book: a list of the other copies, written for search engines.
One value, decided once, flows through every layer
The key idea is that the locale is decided at the edge of the system and then treated as a fixed input by everything after it. Once the request has a locale, the server renders that language, the CDN stores the result under that URL, the browser downloads that language's strings and fonts, and the page links to its siblings in other languages.
Every layer in that picture needs the same answer to "which language is this?". If the answer lives in the URL, every layer can read it for free: the CDN uses the URL as its cache key, the crawler sees it in the link, the server routes on it, and the user can copy it. If the answer lives anywhere else, such as a cookie or a header, each layer has to find it in its own way, and some of them can't.
Language and region are two separate settings
Most products need two settings, not one. Language picks the translation catalog and the text direction. Region picks currency, tax, shipping, and sometimes legal text.
A Swiss user may want German text with prices in Swiss francs. An expat in Dubai may want English text with prices in dirhams.
Keep them apart in your data model even if you only show one switcher today. Put the language in the URL because it changes the content that search engines index. Put the region in the URL only when it changes the content too (different products, different prices that you want indexed), as in /en-gb/ and /en-us/. Otherwise, keep the region as a user setting.
| Question | Language | Region |
|---|---|---|
| Example values | de, fr, ja, pt-BR | CH, CA, AE |
| What it changes | Words, text direction, fonts, number and date formats | Currency, tax, shipping, catalog, legal text |
| Where it lives | In the URL | In the URL only if it changes indexed content; otherwise a setting |
| Good first guess | Accept-Language header | Geo-IP, shown as an editable default |
Check your understanding · Exercise 2
Why is the URL the best place to keep the page's language? Select all that apply.
Locale detection: a polite guess, made once
Detection answers one narrow question: a visitor has arrived at a URL that does not name a language, usually the root /. Which language should they see? Every other URL already names its language, so detection never runs there.
Check the signals from most explicit to least explicit, and stop at the first one that gives an answer.
- The URL.
/de/pricingmeans German. A link someone shared, or a search result someone clicked, must open in the language it names. - A saved choice. A cookie such as
locale=fr, or a language field on the signed-in account, written when the user last used the language switcher. - The browser's languages. The
Accept-Languagerequest header, matched against the locales you support. - A default. Usually the language of your biggest market, often English.
Reading Accept-Language
Browsers send Accept-Language on every request. Its value comes from the language list in browser or operating system settings, which most people never touch, so it usually matches the language of their system. A typical value from a Swiss user looks like this:
Accept-Language: fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7
Each entry is a language tag with an optional quality value q between 0 and 1. No q means 1, and q=0 means "not this language".
Sort by q, highest first, then walk the list and take the first tag you support. If you don't support fr-CH itself, fall back to its base language fr before moving to the next entry. This is close to lookup matching from RFC 4647, the standard for matching language tags, and most i18n routing libraries do something similar.
const SUPPORTED = ["en", "fr", "de", "ja", "pt-BR"];
export function pickLocale(header: string | null): string {
const wanted = (header ?? "")
.split(",")
.map((part) => {
const [tag, ...params] = part.trim().split(";");
const q = params.find((p) => p.trim().startsWith("q="));
return { tag: tag.trim(), q: q ? Number(q.trim().slice(2)) : 1 };
})
.filter((entry) => entry.tag && entry.q > 0)
.sort((a, b) => b.q - a.q);
for (const { tag } of wanted) {
const exact = SUPPORTED.find((l) => l.toLowerCase() === tag.toLowerCase());
if (exact) return exact;
const base = tag.split("-")[0].toLowerCase();
const loose = SUPPORTED.find((l) => l.split("-")[0].toLowerCase() === base);
if (loose) return loose;
}
return "en";
}
pt-PT falls back to pt-BR, which is usually better than English. Strict RFC 4647 lookup would not.Libraries such as @formatjs/intl-localematcher and negotiator do this for you and handle more edge cases, such as * and script subtags. Try your own headers in the negotiator below.
q, then each is tried exactly, then by its base language. A saved choice skips the header entirely. Pick the zh-CN header and switch on zh-Hant: base matching ignores the script and hands Simplified Chinese readers Traditional text.Why not geo-IP?
It is tempting to look up the visitor's country from their IP address and pick its main language. It goes wrong often enough to hurt:
- Countries have many languages. Switzerland has four national languages, Belgium three, Canada two, and India 22 scheduled languages.
- People travel and move. A Japanese tourist in Paris still reads Japanese. An expat in Germany may not read German at all.
- IPs lie. VPNs, corporate proxies, and mobile carriers often route through another country. A user in Austria on a company VPN may appear to be in the Netherlands.
- Crawlers mostly have one location. Google says Googlebot's default IP addresses appear to be in the United States. Redirect by country and Google mostly sees your US page.
Never trap anyone: bots, links, and the escape hatch
The most damaging detection bug is an automatic redirect on every page. A French user clicks a shared link to /en/guide, the site sees Accept-Language: fr and bounces them to /fr/guide. Now they cannot read the English page even if they want to, and the person who shared it sees something different from them.
Crawlers suffer the same fate. Google states that Googlebot sends requests without an Accept-Language header, so a site that redirects every page by language sends the crawler to the default language every time, and the other languages never get indexed. Google's guidance is plain: avoid redirecting users to another language version based on what you think their language is. Three rules avoid all of this:
- Redirect only at URLs that name no language, such as
/. Every locale URL serves its own language to everyone, crawlers included. - On a locale URL, if the browser prefers another language you support, suggest it with a small banner ("This page is available in English") instead of redirecting. Remember a dismissal.
- Put a language switcher on every page, with each language written in its own name (
Deutsch,Français,日本語), not in the current language and not as a flag. Flags stand for countries, not languages.
Check your understanding · Exercise 3
A user with Accept-Language: de-AT, de;q=0.9, en;q=0.5 and no cookie opens /fr/hotels. Your site supports en, de, and fr. What should happen? Pick one.
URL strategies: where the language goes in the address
Once you accept one URL per language, the next decision is its shape. There are four common patterns. All of them can work; they differ in cost, in how much each language can be run separately, and in what they tell search engines.
Decision How should the language appear in the URL?
What we pick: Use subdirectories by default: they are the cheapest to run and give every language the domain's reputation. Choose subdomains when separate teams or hosting per language is a real need, and country domains only when you run truly separate businesses per country. Avoid query parameters for public pages, and never use a cookie alone.
The fifth option, and why it is a trap
Some apps keep one URL for all languages and pick the language from a cookie or from Accept-Language. It looks clean, but it breaks the rule that one URL shows one language.
Crawlers carry no cookie, so they see only the default. Shared links open in the reader's language. And a CDN either can't cache the page or must keep one copy per cookie value.
This is acceptable only for screens behind a login that search engines never see, such as a dashboard. Even there, a locale in the URL makes support easier: "send me the link" then shows exactly what the user saw.
Translated paths and the language switcher
You can translate the rest of the path too: /fr/tarifs instead of /fr/pricing. It reads better for users and may help a little with search in that language. The cost is that the language switcher can no longer just swap the prefix. It needs a map from each page to its twins.
// One route id, one path per locale. The switcher and hreflang both read this.
const routes = {
pricing: { en: "/en/pricing", fr: "/fr/tarifs", de: "/de/preise" },
hotel: { en: "/en/hotels/:id", fr: "/fr/hotels/:id", de: "/de/hotels/:id" },
} as const;
export function alternates(routeId: keyof typeof routes, params: Record<string, string>) {
return Object.entries(routes[routeId]).map(([locale, pattern]) => ({
locale,
href: pattern.replace(/:(\w+)/g, (_, key) => params[key]),
}));
}
Keep slugs for user content, like hotel names, stable across languages unless you have translated names. And keep the same ids in every locale's path, so a support engineer can map /de/hotels/812 to /fr/hotels/812 at a glance.
Check your understanding · Exercise 4
A SaaS company with one engineering team is launching marketing pages in six languages. Which URL plans are sound? Select all that apply.
hreflang: telling search engines about every copy
Having a URL per language is only half the job. A search engine that finds /en/pricing, /fr/tarifs, and /de/preise sees three pages with the same layout and the same numbers. Without help it has to guess which one suits each searcher, and it may show the English page to people searching in French. Versions in the same language, such as en and en-GB, can even be folded together as duplicates.
hreflang annotations fix that. Each page lists every language version of itself, including itself, with the language each one is for. Search engines then show the French page to people searching in French and the German page to people searching in German. hreflang is not a ranking boost; it swaps in the right version of a page that would rank anyway.
What the tags look like
<!-- In the head of https://example.com/de/preise, served as <html lang="de"> -->
<link rel="canonical" href="https://example.com/de/preise" />
<link rel="alternate" hreflang="en" href="https://example.com/en/pricing" />
<link rel="alternate" hreflang="en-GB" href="https://example.com/en-gb/pricing" />
<link rel="alternate" hreflang="fr" href="https://example.com/fr/tarifs" />
<link rel="alternate" hreflang="de" href="https://example.com/de/preise" />
<link rel="alternate" hreflang="x-default" href="https://example.com/pricing" />
- Values are a language code (ISO 639-1, like
de), optionally followed by a region code (ISO 3166-1, likede-AT). A region alone is not valid. Scripts work too, as inzh-Hant. - Self-reference. Each page lists itself as well as the others.
- Reciprocal. If the English page names the German page, the German page must name the English page back. A one-way link is ignored, because otherwise anyone could claim to be a translation of your page.
- x-default names the page for everyone else: users whose language you don't have. It is often a language picker, a root that redirects by language (Google allows this case), or the English page.
- Absolute URLs, with the protocol and host, so there is no doubt which page is meant.
/en/pricing, makes search engines ignore or drop a version.Three places to put the annotations
| Place | How | Best for |
|---|---|---|
| HTML head | link rel="alternate" tags, as above | Most sites with a handful of languages |
| HTTP header | Link: <https://example.com/de/preise>; rel="alternate"; hreflang="de" | Files that have no head, such as PDFs |
| XML sitemap | An xhtml:link element for each version inside each url entry | Many languages or many pages: keeps the HTML small and the list in one generated file |
Google treats the three as equivalent and gets no benefit from using more than one, so pick one: two copies of the list are harder to keep in step. With 30 locales, the head approach adds 31 tags to every page and every HTML response, so large sites often move them to the sitemap, generated from the same route map that drives the language switcher.
The mistakes that make search engines ignore you
- Canonical points elsewhere. Every language page has
canonicalset to the English page. That tells search engines the German page is a duplicate of the English one, which contradicts hreflang; the German page often drops out. Each page's canonical must be itself. - Missing return links. A new locale launches and its pages link to the old ones, but the old pages were cached or built before the new locale existed. Generate the list from one source and rebuild every locale together.
- Wrong codes.
en-UKis invalid (the region code isGB).hreflang="uk"means Ukrainian, not the United Kingdom.jpis not a language code; Japanese isja. - Links to redirects or errors. Every language URL in the list should answer
200directly. Linking to a URL that redirects by language means the crawler never reaches the target. Only thex-defaultentry may point at a redirecting root. - Translated only in JavaScript. If the server sends English HTML and the browser swaps in French text later, crawlers that don't run scripts, and social media link previews, see English.
Check your understanding · Exercise 5
Your Spanish pages are not showing in Google Spain. Which of these could explain it? Select all that apply.
Rendering each locale on the server, and caching it
Search engines, link previews, and users on slow phones all benefit from HTML that already contains the right language. Server-side rendering (SSR) or static generation (SSG) per locale gives every language the same first paint and the same visibility as the default one. Client-only translation, where English HTML arrives and JavaScript swaps the words, gives the other languages a worse experience on both counts.
What the server does for each request
- Read the locale from the URL (
/fr/...givesfr). Unknown prefix: answer404, don't guess. - Load that locale's messages for this route, and only this route.
- Render with
html lang="fr"and the rightdir(rtlfor Arabic or Hebrew), a translatedtitleand meta description, and the hreflang list. - Embed the messages the page needs for hydration (making the server HTML interactive in the browser), so the client does not fetch them again.
- Send it with cache headers. The language is in the URL, so the response is the same for every visitor to that URL.
With static generation, the build renders every page once per locale. That multiplies build time: 2,000 pages in 12 locales is 24,000 pages. Large sites pre-render only the most visited pages per locale and render the rest on first request, with incremental static regeneration (ISR) or a CDN in front of SSR.
The root redirect, step by step
Detection runs only at /. The answer depends on the visitor, so it is sent as a temporary redirect, a 307 or 302. A permanent 301 would be cached by the browser and could send everyone on a shared computer to German forever. After that, every request has a language in the URL and is served from the CDN.
/, with a temporary redirect that is not shared. /de/ is the same for every German visitor, so the CDN caches it with the URL alone as the key.GET / HTTP/2
Accept-Language: de-DE,de;q=0.9,en;q=0.8
HTTP/2 307
Location: /de/
Cache-Control: private, no-store
Vary: Accept-Language, Cookie
Why Vary: Accept-Language hurts
If a single URL can return different languages, the response must say so with Vary: Accept-Language. That tells caches to store a separate copy for each distinct value of the header. The trouble is that Accept-Language values are extremely varied: the order, the q values, the regions, and the spacing all differ from browser to browser and user to user.
A site with 5 languages may see thousands of distinct Accept-Language strings. A cache that keys on the raw string stores one copy of the same French page for fr-FR,fr;q=0.9, another for fr-FR,fr;q=0.9,en;q=0.8, another for fr,en-US;q=0.7, and so on. Most of those copies are requested once and never again, so the hit ratio collapses. CDNs also differ: some refuse to cache such responses at all, and Cloudflare ignores Vary on most headers, so it would serve whichever language it cached first.
If you truly cannot put the locale in the URL, normalize at the edge: an edge function reduces the header to one of your supported locales and adds that short value to the cache key. Five locales means at most five copies. This is the same idea as the locale in the URL, done in a more fragile place.
Server and browser must agree
Hydration fails, or text flickers, when the server and the browser format something differently. The common causes are a server that formats in UTC while the browser uses the user's time zone, a server Node.js build with different ICU locale data from the browser, and a client that reads navigator.language instead of the URL's locale. Pass the locale from the URL to both sides, pass a time zone explicitly, and format times that depend on the viewer only in the browser.
Check your understanding · Exercise 6
Which setups let a CDN cache public pages well in every language? Select all that apply.
Translation bundle size: ship one language, one route
Translations are data, and data has weight. An app with 5,000 strings at about 60 bytes each holds roughly 300 KB of text per language before compression. Put 20 locales in the main JavaScript bundle and every visitor downloads about 6 MB of words in languages they will never read, then the browser parses all of it.
The fix comes in layers, each building on the one before. Move the sliders to see how much each layer saves for your app's size.
Layer 1: one locale per visitor
Never import catalogs statically. Load the active locale's messages with a dynamic import(), so the bundler emits one file per locale and the browser fetches only one of them. With server rendering it is even simpler: the server knows the locale from the URL and embeds the right messages in the HTML.
// Every path is written out, so the bundler makes one chunk per file; only the active one loads.
const checkoutMessages = {
en: () => import("./messages/en/checkout.json"),
fr: () => import("./messages/fr/checkout.json"),
de: () => import("./messages/de/checkout.json"),
ja: () => import("./messages/ja/checkout.json"),
} as const;
// Route code and its strings start downloading at the same time.
const [Checkout, messages] = await Promise.all([
import("./routes/Checkout"),
checkoutMessages[locale](),
]);
Layer 2: one route's strings
Group keys into namespaces that follow your routes: common for the header, footer, and buttons used everywhere, then checkout, search, account, and so on. A page loads common plus its own namespace. If common holds about a fifth of all strings, as the calculator assumes, and the rest is spread over 10 routes, a page loads under a third of the catalog. The Translating an app lesson covers how to organize namespaces and fallbacks.
Layer 3: compile messages at build time
ICU MessageFormat strings such as {count, plural, one {# night} other {# nights}} must be parsed before use. If you ship them raw, you also ship a parser and pay for parsing at runtime. FormatJS and Lingui can compile messages at build time into a compact form, so the browser skips the parse step and the parser can be left out of the bundle.
Layer 4: use the browser's locale data
Dates, numbers, currencies, plural rules, and list formats need per-locale data. Older libraries bundled that data in JavaScript, often hundreds of KB for all locales. Modern browsers ship it: Intl.DateTimeFormat, Intl.NumberFormat, Intl.PluralRules, Intl.RelativeTimeFormat, and Intl.ListFormat cost zero bytes to download. Polyfill only for the old browsers you truly support, and load the polyfill's locale data per locale too.
| Approach | Translation bytes per page view (20 locales, 5,000 strings) | Cost |
|---|---|---|
| All locales in the main bundle | about 6 MB raw | Every user pays for every language |
| Active locale only | about 300 KB raw | One dynamic import or server embed |
Active locale, common plus one route | about 84 KB raw, much less gzipped | Namespaces that follow routes |
Plus compiled messages and Intl | the same text, no parser or locale data | A build step |
Check your understanding · Exercise 7
Which changes reduce the bytes a Japanese user downloads on the checkout page? Select all that apply.
Text expansion, pseudo-localization, and fonts per script
Translations are rarely the same length as the English they replace. German, Finnish, and Russian often run about 30% longer over a paragraph. Short labels grow far more: Save becomes Speichern, more than twice as long, and Edit becomes Bearbeiten. Chinese and Japanese go the other way, with fewer characters, but each character is wider and usually needs a larger font size to stay readable.
| English length | Typical translated length | Example |
|---|---|---|
| Up to 10 characters | Can be 2 to 3 times as long | Save to Speichern |
| 11 to 30 characters | About 1.6 to 2 times | Add to cart to In den Warenkorb legen |
| 31 to 70 characters | About 1.4 to 1.7 times | Form hints and error messages |
| Over 70 characters | About 1.3 times | Paragraphs |
min-inline-size and padding, or let them wrap; a fixed width breaks.Layouts that survive longer text
- Size to content. Buttons, tabs, chips, and table headings get
min-inline-sizeand padding, not a fixedwidth. Flexbox and grid withautotracks grow with the label. - Allow wrapping. A two-line button is better than a cut-off one. Don't set
white-space: nowrapon labels unless you have checked every language. - Ellipsis last. Truncate only where a full value is available elsewhere, and show it on hover and focus. A truncated button label can hide what the button does.
- No text in images. Translators can't reach it, and screen readers can't read it.
- Tell translators the limits. If a tab fits 14 characters, say so in the key's description, so they can choose a shorter word.
- Mind vertical space. Thai, Devanagari, and Vietnamese stack marks above and below letters. A fixed
heightwith a tightline-heightclips them; prefer a unitlessline-heightaround 1.5. - Set
langeverywhere. CSShyphens: autoonly hyphenates when the element's language is known, and it helps long German compounds a lot.
Pseudo-localization: find the bugs before translators start
Pseudo-localization replaces every string with a fake translation that is still readable to the team: accented letters, padding to make it 30 to 50% longer, and brackets at both ends. Save changes becomes something like [Šåvé çĥåñĝéš one two]. Run the app in this fake locale and three classes of bug jump out at once.
- Overflow and clipping. Padded text shows every fixed-width box. A missing closing bracket means the end was cut off.
- Hard-coded strings. Any plain English left on screen never went through the translation system.
- Concatenation. Brackets in the middle of a sentence, like
[You have] 3 [items], show strings glued together in code, which translators can't reorder.
Android ships pseudo-locales en-XA (accented and longer) and ar-XB (right-to-left) for exactly this. Web teams usually generate a pseudo-locale in the build from the source catalog and enable it in staging and in visual regression tests. Try it below.
Fonts per script
Fonts follow the script, not the language. A Latin web font with 200 to 300 glyphs might be 20 to 40 KB as WOFF2. A Japanese or Chinese font needs thousands of glyphs and can be several MB per weight. Shipping that to every visitor, or shipping a Latin font to Japanese readers who will never use it, wastes bytes in both directions.
- Subset by script. Split each font file by Unicode range and declare each part with
unicode-rangein@font-face. The browser downloads a part only if the page uses characters from it. Google Fonts cuts each Chinese, Japanese, and Korean (CJK) font into over a hundred slices this way. - Preload per locale. Preload only the font file that the current locale's first screen needs. The French page preloads Latin; the Japanese page preloads nothing or a small Japanese slice.
- Consider system fonts for CJK. Every major operating system ships good Chinese, Japanese, and Korean fonts. A system font stack costs zero bytes.
- Pick the right glyph variant. Chinese, Japanese, and Korean share many code points but draw some differently. The
langattribute lets the browser pick the right glyphs from a font that covers all three.
Check your understanding · Exercise 8
Pseudo-localization in staging shows [Šéårçĥ with no closing bracket on a tab. What does that tell you? Select all that apply.
Failure modes, real products, and a lab
Most multilingual bugs show up in one of three places: search traffic for a language that stays flat, support tickets about the wrong language, or a CDN hit ratio that drops after a launch. This table maps the usual symptoms to their causes.
| Symptom | Likely cause | Fix |
|---|---|---|
| A language gets almost no search traffic | Canonical points to the default language, hreflang is not reciprocal, or pages redirect crawlers | Self-canonical per locale, hreflang from one route map, no redirects on locale URLs |
| Users see another user's language | Language chosen by cookie or header at a cached URL without the locale in the cache key | Locale in the URL; keep cookies out of public cache keys |
| CDN hit ratio falls after adding languages | Vary: Accept-Language or Vary: Cookie on public pages | Locale in the URL; normalize at the edge if you can't |
| Users can't reach the English page | Automatic redirect by Accept-Language or IP on every page | Detect only at /; suggest with a banner elsewhere |
| Root keeps switching back to the browser language | The switcher doesn't save the choice, or detection ignores it | Cookie or account setting that wins over the header |
| Main bundle grew by hundreds of KB | A static import of a catalog or a library's locale data | Dynamic import per locale and route; size check in CI |
| Hydration warnings on dates | Server and browser format with different time zones or locales | Pass locale and time zone explicitly |
| Cut-off buttons in German | Fixed widths sized for English | Size to content, wrap, pseudo-localize in CI |
How well-known sites do it
- Wikipedia uses a subdomain per language:
en.wikipedia.org,fr.wikipedia.org. Each language edition is a separate community with separate articles, so separate hosts match the content. - MDN Web Docs puts the locale first in the path, as in
/en-US/docs/Web/HTTP. A link without a locale is redirected to a locale path; every locale URL then serves its own language. - Apple uses country paths that also imply a language, such as
apple.com/fr/for France andapple.com/ca/fr/for French in Canada, because prices, products, and legal text differ by country. - Google Search used country domains like
google.frandgoogle.defor years. In 2025 it began redirecting them togoogle.com, because it already picks local results from the user's location, not the domain. - Next.js shows the pattern in its docs: a proxy file (called middleware before Next.js 16) reads
Accept-Languageon requests whose path has no locale and redirects to/[lang]/.... Pages live under anapp/[lang]folder and are rendered per locale, withgenerateStaticParamslisting the locales to pre-render.
Lab: choose a URL strategy and a detection method
Pick a URL strategy and a detection method for the travel site from chapter 1, then read the three outcomes: CDN cache hit ratio for HTML, how search engines index the languages, and what users experience. The numbers come from a simple model, not measurements; what matters is the direction of each change.
Notice that the two choices interact. A good URL strategy is undone by a redirect on every page, and the most careful detection can't save a cookie-only URL. The winning pair is the boring one: locale in the path, detection only at the root with a saved choice, and a banner for suggestions.
Check your understanding · Exercise 9
Using the lab, which combinations keep every language indexed? Select all that apply.
Using it in a system design interview
Internationalization comes up whenever the prompt says global, worldwide, or names markets: "design a booking site for Europe and Japan", "design the marketing site and docs for a developer tool". It also comes up as a follow-up: "how would you launch this in 10 languages?". Don't wait for the follow-up; one minute on it early shows range.
On the whiteboard, add the locale to the URLs you already drew, put a small detection box in front of the root only, and write lang and hreflang next to the server render. That is enough to show the whole idea in under a minute, and it gives the interviewer something concrete to probe.
What to say, in order
- URLs. "Each language gets its own URL,
/fr/..., because the CDN, crawlers, and shared links all key on the URL." - Detection. "Only the root detects: saved choice, then
Accept-Language, then default, with a307. No redirects on locale URLs, no geo-IP for language, a switcher on every page." - SEO. "Server-rendered per locale, self-canonical, reciprocal hreflang with
x-default, generated from one route map." - Caching. "Locale URLs are cached at the CDN with no
Varyon language; only the root redirect is private." - Bytes. "One locale, split by route, messages compiled,
Intlfor formatting, fonts subset per script." - Layout. "Components size to content, logical CSS properties for RTL, pseudo-localization in CI."
How deep to go by level
| Level | What a strong answer shows |
|---|---|
| Junior | Locale in the URL, load only the active language, a language switcher, layouts that allow longer text |
| Mid-level | Detection order and why not geo-IP, temporary root redirect, hreflang basics, per-route catalogs, SSR per locale |
| Senior | Cache key reasoning (Vary fragmentation, edge normalization), hreflang failure modes, build cost of SSG times locales, per-locale performance budgets and monitoring |
| Staff | Language versus region as separate dimensions, organization of translation workflow and CI checks, rollout of a new locale without breaking existing hreflang clusters |
Think first
You are adding Korean to a site that already has 8 locales. What has to change beyond translating strings?
Add ko to the supported list used by routing, detection, and the switcher, all read from one config. Generate the /ko/ routes and add Korean to the route map, so every existing page's hreflang list and the sitemap gain ko in the same deploy, keeping links reciprocal.
Load a Korean font strategy (system fonts or unicode-range slices), check line breaking with word-break: keep-all where needed, and run visual tests. Pre-render the top pages for ko and let the rest render on demand. Watch Korean in real user monitoring and Search Console after launch.
Summary
- One URL shows one language, always. The URL is the cache key, the crawler's map, and the shareable link, so the locale belongs in it: subdirectories by default.
- Detection only suggests, and only where the URL names no language: saved choice, then
Accept-Language, then default, with a temporary redirect. Never geo-IP for language, never redirect locale URLs. - hreflang lists every version on every page, including itself and
x-default, with valid codes, reciprocal links, and a self-referencing canonical. Generate it from one route map. - Render each locale on the server so crawlers and slow phones get the right language in the first HTML. Cache locale URLs at the CDN with no
Varyon language. - Ship one locale and one route's strings, compile messages at build time, and use
Intlinstead of bundled locale data. - Design for text that is 30% longer, and short labels that double. Size to content, allow wrapping, and pseudo-localize in CI.
- Fonts follow scripts: subset with
unicode-range, preload per locale, and measure performance per locale.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
A first-time visitor from Switzerland opens your home page. Which language do they see, and how did you decide?
The root URL is the only place detection runs. There is no saved cookie, so I read Accept-Language, say fr-CH, fr;q=0.9, en;q=0.8, sort by quality, and match against supported locales. We don't support fr-CH, but its base fr matches, so I answer 307 with Location: /fr/ and mark the redirect private.
I don't use the IP address: Switzerland has four national languages, and VPNs and travellers make the country a poor guess. The French page shows a switcher with each language in its own name, and any choice is saved in a cookie that wins over the header next time.
Question 2
Your French pages don't show up in Google France, but the English ones do. Walk me through the debugging.
First, do French pages have their own URLs, or does the language come from a cookie? Then fetch a French URL with no cookie and no Accept-Language, as a crawler would: does it answer 200 in French, or redirect? Then check the HTML: server-rendered French text, self-referencing canonical, and hreflang listing all versions with valid codes and x-default.
Then check reciprocity: does the English page list the French one back? Finally, look in Search Console for hreflang and indexing errors. The usual culprits are a canonical pointing at English, a redirect on locale URLs, or client-only translation.
Question 3
Why not serve every language at the same URL and add Vary: Accept-Language?
Because three systems key on the URL. Crawlers usually send no Accept-Language, so they index only the default language. Shared links open in the reader's language, not the sharer's. And caches keyed on the raw header store a copy per distinct header string, of which there are thousands, so the hit ratio collapses or the CDN refuses to cache.
Normalizing the header to a supported locale at the edge fixes the cache but not search or sharing. Putting the locale in the URL fixes all three at no cost.
Question 4
How do you support 30 languages without making the app slower?
Load only the active locale, split into a small common namespace plus one namespace per route, fetched in parallel with the route's code or embedded in the server HTML. Compile ICU messages at build time so no parser ships. Use Intl for dates, numbers, and plurals instead of bundled locale data.
Subset fonts per script with unicode-range and preload only what the locale's first screen needs. Pre-render top pages per locale, render the rest on demand, and cache every locale URL at the CDN. Track Core Web Vitals per locale, since a Japanese page can be slower than the English one for font reasons alone.
Question 5
The German checkout button is cut off. How do you fix it now, and how do you stop it from happening again?
Now: remove the fixed width, use min-inline-size and padding so the button grows, allow wrapping to two lines, and check the row still works on a narrow phone. If space is truly fixed, ask the translator for a shorter wording with a length note.
Later: a pseudo-locale that pads strings by 30 to 50% in staging and in visual regression tests, length limits in key descriptions for tight spots, and a design-system rule that text containers size to content.
Practice it
These design problems lean on locales, routing, and seo.
References
- Google Search Central: Managing multi-regional and multilingual sites URL structures, why not to use cookies or IP for language
- Google Search Central: Tell Google about localized versions of your page hreflang in HTML, headers, and sitemaps; x-default; common mistakes
- Google Search Central: How Google crawls locale-adaptive pages Googlebot's default US IP addresses and missing Accept-Language
- MDN: Accept-Language Header syntax and quality values
- MDN: Vary How caches store variants
- W3C Internationalization: Text size in translation IBM's expansion figures by string length
- MDN: unicode-range Subsetting fonts by script
- Next.js: Internationalization Locale routing and detection in the proxy file (middleware before Next.js 16)
Read next
- Translating an appHow an app speaks many languages: text in per-locale message catalogs, ICU messages so plurals and gender work everywhere, catalogs loaded per locale and route, clear fallbacks, and a safe workflow with translators.
- Dates, numbers, and pluralsTurning raw instants, numbers, amounts, and counts into text that reads right for each user with the browser's built-in Intl APIs, instead of hand-built strings that are correct for only one country.
- Right-to-left layoutsHow to build layouts that mirror correctly for Arabic, Hebrew, Persian, and Urdu readers: the
dirattribute, CSS logical properties, which icons to flip, text that mixes both directions, and how to test all of it. - Server-side renderingThe server builds the full HTML for each request, so users see content before any JavaScript runs, then the browser hydrates it: faster first paint and better SEO, paid for with server work, a later first byte, and a second render in the browser.
- CDN cachingKeeping shared copies of your responses on servers near every user, so most requests never travel to your origin: the biggest lever for global speed and for surviving traffic spikes.
- Code splitting and bundlingBreaking one big JavaScript bundle into smaller pieces that load only when needed, because every kilobyte of JavaScript costs download, parse, and run time on the user's device.