cf.completefrontend
My progress
FUNDAMENTALS · MODULE 13

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.

  1. Detection. When someone arrives, which language do you show, and how do they change it?
  2. URLs. Does each language have its own address, and what shape does it take: /fr/, fr.example.com, example.fr, or ?lang=fr?
  3. hreflang. How do you tell search engines that five pages are the same content in five languages?
  4. Rendering and caching. How does the server render the right language, and how does a CDN cache it without mixing languages?
  5. Bytes. How do you avoid shipping every language's strings, date data, and fonts to every user?
  6. 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.

Three small screens, each showing one bug. Search: a French query returns the English page /en/hotels, and the French page is missing. Cache: an English reader asks for /hotels and gets the French copy that the CDN cached. Checkout: the German button label Zahlungspflichtig bestellen is cut off after Zahlungspflich.Searchhôtel paris/en/hotels/fr/hotelsFrench not indexedCachereader wants en/hotelsserved: fr copycached oncewrong languageCheckoutEUR 1.240,00Zahlungspflichfixed width: 164label cut off
Every string here was translated correctly. The bugs come from URLs, cache keys, and layout: decisions the whole system has to make.

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.

A stack of five layers, with an arrow labelled locale fr running down beside them. URL: /fr/hotels/paris names the locale. CDN cache: the cache key is the URL, with no Vary on language. Server render: lang fr, French messages, and hreflang tags. Browser: French strings for this route and a Latin font. Crawlers and shared links: index and share /fr/ URLs.frURL/fr/hotels/paris names the localeCDN cachecache key = URL, no Vary on languageServer renderlang="fr", fr messages, hreflangBrowserfr strings for this route, Latin fontCrawler, linksindex /fr/, share /fr/ links
The locale enters once, in the URL, and flows down as a fixed input. No layer has to guess it from a cookie or a header.

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.

QuestionLanguageRegion
Example valuesde, fr, ja, pt-BRCH, CA, AE
What it changesWords, text direction, fonts, number and date formatsCurrency, tax, shipping, catalog, legal text
Where it livesIn the URLIn the URL only if it changes indexed content; otherwise a setting
Good first guessAccept-Language headerGeo-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.

  1. The URL. /de/pricing means German. A link someone shared, or a search result someone clicked, must open in the language it names.
  2. 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.
  3. The browser's languages. The Accept-Language request header, matched against the locales you support.
  4. A default. Usually the language of your biggest market, often English.
A decision chain of four checks, stopping at the first yes. Locale in the URL? If yes, use it: /de/pricing gives de. If no, a saved choice? A cookie locale=fr gives fr. If no, does Accept-Language match a supported locale? de-CH matches de. If nothing matched, use the default, en. A note below says geo-IP is not on the list, because a country is not a language.Locale in the URL?/de/pricing: deyesnoSaved choice?cookie locale=fryesnoAccept-Language match?de-CH matches deyesnoNothing matcheddefault: enNot on the list: geo-IP. A country is not a language.
Explicit beats inferred: the URL, then the user's own choice, then the browser's languages, then a default. Only the first check runs on locale URLs, so they never redirect.

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:

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

ts
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";
}
Exact tag first, then the base language, then the next preference. This version also accepts a regional variant of the base language, so 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.

Example headers
Site supports
Saved choice
Matching the header fr-CH, fr;q=0.9, en;q=0.8 against en, fr, de, ja. fr-CH with q 1: base: fr; fr with q 0.9: not checked; en with q 0.8: not checked. Result: redirect / to /fr/, because of base language fr of fr-CH.tagqoutcomefr-CH1base: frfr0.9not checkeden0.8not checked307 Location: /fr/base language fr of fr-CH
Entries are sorted by 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?

Subdirectory: example.com/fr/

One domain; the first path segment names the locale, as in /fr/hotels/paris.

One app routes on that segment, often a [lang] route folder.

Pros

  • One domain, one certificate, one deploy, one analytics property.
  • New languages inherit the domain's existing search reputation.
  • Cookies and sessions are shared across languages for free.

Cons

  • All languages share one hosting setup, so you can't host Japanese in Tokyo separately without edge routing.
  • The root / needs a rule: detect and redirect, or serve the default language.

Subdomain: fr.example.com

Each language gets its own hostname, as Wikipedia does with fr.wikipedia.org.

DNS can point each one at different servers.

Pros

  • Each language can be hosted, deployed, or owned by a separate team.
  • Clean separation in logs and analytics.

Cons

  • More DNS records and certificates (a wildcard certificate helps).
  • Cookies need Domain=example.com to be shared, and each host warms its CDN cache separately.

Country domain: example.fr

A country-code top-level domain (ccTLD) per market, such as .fr, .de, .co.jp.

Pros

  • The strongest local signal: users in France trust .fr, and search engines treat it as targeting France.
  • Fully separate sites if markets differ a lot.

Cons

  • Targets countries, not languages: what goes on example.ch?
  • Each domain must be bought, renewed, and sometimes needs a local presence.
  • Search reputation is built per domain, so a new domain starts with little. Logins across domains need extra work.

Query parameter: example.com/hotels?lang=fr

The same path for every language, with the locale in a parameter.

Pros

  • Easy to add to an existing app without changing routes.

Cons

  • Google's own guidance lists URL parameters as not recommended for language versions.
  • Easy to drop: internal links, redirects, and canonical tags often lose the parameter.
  • Some CDN setups leave the query string out of the cache key, which serves one language to everyone.

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.

A comparison matrix of five URL strategies against cost, search, CDN caching, shareable links, and separate hosting. Subdirectory /fr/: low cost, good search, good CDN, good links, shared hosting. Subdomain: medium cost, good search, CDN, and links, hosting can be split. Country domain .fr: high cost, a country signal for search, good CDN and links, split hosting. Query parameter: low cost, weak for search, fragile for CDN and links, shared hosting. Cookie only: low cost, one language indexed, CDN and links break, shared hosting.costsearchCDNlinkshosting/fr/ pathlowgoodgoodgoodsharedfr. subdomainmediumgoodgoodgoodsplit.fr domainhighcountrygoodgoodsplit?lang=frlowweakfragilefragilesharedcookie onlylow1 langbreaksbreakshared
Only the cookie-only row has no locale in the URL, and it is the only one that breaks caching and links. Among the rest, the choice is mostly about cost and how separately each language must run.

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.

ts
// 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 one source of truth for every page's localized paths. The language switcher, the hreflang tags, and the sitemap all read from it, so they can never disagree.

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

html
<!-- 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" />
Every version of the page carries this same list. Only the canonical line changes: each page's canonical points to itself.
  • Values are a language code (ISO 639-1, like de), optionally followed by a region code (ISO 3166-1, like de-AT). A region alone is not valid. Scripts work too, as in zh-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.
Three pages, /en/pricing, /de/preise, and /fr/tarifs, with arrows both ways between every pair, because every page lists all versions including itself. A fourth page, /pricing, is the x-default for other languages. A note says each page's canonical is the page itself./en/pricinghreflang="en"/de/preisehreflang="de"/fr/tarifshreflang="fr"/pricingx-defaultcanonical:the page itself
The links go both ways between every pair. A missing return link, or a canonical pointing at /en/pricing, makes search engines ignore or drop a version.

Three places to put the annotations

PlaceHowBest for
HTML headlink rel="alternate" tags, as aboveMost sites with a handful of languages
HTTP headerLink: <https://example.com/de/preise>; rel="alternate"; hreflang="de"Files that have no head, such as PDFs
XML sitemapAn xhtml:link element for each version inside each url entryMany 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 canonical set 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-UK is invalid (the region code is GB). hreflang="uk" means Ukrainian, not the United Kingdom. jp is not a language code; Japanese is ja.
  • Links to redirects or errors. Every language URL in the list should answer 200 directly. Linking to a URL that redirects by language means the crawler never reaches the target. Only the x-default entry 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

  1. Read the locale from the URL (/fr/... gives fr). Unknown prefix: answer 404, don't guess.
  2. Load that locale's messages for this route, and only this route.
  3. Render with html lang="fr" and the right dir (rtl for Arabic or Hebrew), a translated title and meta description, and the hreflang list.
  4. Embed the messages the page needs for hydration (making the server HTML interactive in the browser), so the client does not fetch them again.
  5. 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.

A sequence diagram between Browser, CDN edge, and Server. The browser asks for / with Accept-Language de. The edge answers 307 with Location /de/, marked private. The browser asks for /de/. The edge misses its cache and asks the server, which returns German HTML with lang de. The edge returns it and caches it under /de/. The next visitor asks for /de/ and the edge answers from its cache with a hit.BrowserCDN edgeServerGET / Accept-Language: de307 Location: /de/ (private)GET /de/GET /de/ (cache miss)200 German HTML, lang=de200, stored under /de/next visitor: GET /de/200 from cache (HIT)
Detection runs once, at /, 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.
http
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
The root redirect depends on headers, so it must not be shared from a CDN cache unless the cache key includes the detected locale. The pages it points to need neither header in their cache key.

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.

Two cache tables for the same traffic to the hotels page. Left, one URL with Vary: Accept-Language: six of thousands of keys, such as fr-FR,fr;q=0.9 with 3 hits and fr,en-US;q=0.7 with 1 hit, almost all requested once or twice. Right, locale in the URL: three keys, /en/hotels with 540 hits, /fr/hotels with 312, and /de/hotels with 208.Vary: Accept-Languagefr-FR,fr;q=0.93fr-FR,fr;q=0.9,en;q=0.81fr,en-US;q=0.71fr-CA,fr;q=0.8,en2de-DE,de;q=0.91de,en;q=0.51+ thousands more keyslocale in the URL/en/hotels540/fr/hotels312/de/hotels2083 keys, mostly hits
Every French row on the left holds the same French page, stored again for each header string. With the locale in the URL, the cache holds one copy per language and almost every request is a hit.

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.

Translation text downloaded per page view, for 5000 strings, 20 languages, and 10 routes. All locales: 6.0 MB. Current locale: 300 KB. Current locale and route: 84 KB.All locales in the bundle6.0 MBCurrent locale only300 KBLocale + this route84 KBtranslation text per page view, before compression
Assumes about 60 bytes per string before compression, with 20% of strings in a shared namespace used by every route. Splitting by locale and route sends 1.4% of the all-in-one text.

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.

ts
// 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](),
]);
Fetch a route's code and its strings in parallel. Loading the strings only after the code has run adds a full round trip to every navigation.

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.

Numbers from the calculator's defaults, before compression. Text compresses well: gzip or Brotli often cuts it by two thirds or more.
ApproachTranslation bytes per page view (20 locales, 5,000 strings)Cost
All locales in the main bundleabout 6 MB rawEvery user pays for every language
Active locale onlyabout 300 KB rawOne dynamic import or server embed
Active locale, common plus one routeabout 84 KB raw, much less gzippedNamespaces that follow routes
Plus compiled messages and Intlthe same text, no parser or locale dataA 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.

IBM's average expansion figures for English into European languages, as quoted by the W3C, with some rows merged. The shorter the string, the bigger the relative growth: plan most space for buttons, tabs, and labels.
English lengthTypical translated lengthExample
Up to 10 charactersCan be 2 to 3 times as longSave to Speichern
11 to 30 charactersAbout 1.6 to 2 timesAdd to cart to In den Warenkorb legen
31 to 70 charactersAbout 1.4 to 1.7 timesForm hints and error messages
Over 70 charactersAbout 1.3 timesParagraphs
Three rows of a Save changes button in English and German. With a fixed width, the English fits but the German, Änderungen speichern, runs out of the box. Sized to its text, the German button grows to fit. Allowed to wrap, the German label breaks onto two lines inside the box.EnglishGermanfixed widthSave changesÄnderungen speichernsized to textSave changesÄnderungen speichernwrapsSave changesÄnderungenspeichern
German is about 70% longer here. Size buttons to their content with 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-size and padding, not a fixed width. Flexbox and grid with auto tracks grow with the label.
  • Allow wrapping. A two-line button is better than a cut-off one. Don't set white-space: nowrap on 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 height with a tight line-height clips them; prefer a unitless line-height around 1.5.
  • Set lang everywhere. CSS hyphens: auto only 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.

Button width
The English label Save changes becomes the pseudo-localized [Šåvé çĥåñĝéš one], 18 characters. In a fixed-width button it fits, closing bracket included.sourceSave changespseudo-locale[Šåvé çĥåñĝéš one]closing bracket visible: it fits
Accents check that every character renders, padding makes the text longer like a real translation, and the brackets show at a glance whether the whole string made it onto the screen.

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-range in @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 lang attribute 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.

SymptomLikely causeFix
A language gets almost no search trafficCanonical points to the default language, hreflang is not reciprocal, or pages redirect crawlersSelf-canonical per locale, hreflang from one route map, no redirects on locale URLs
Users see another user's languageLanguage chosen by cookie or header at a cached URL without the locale in the cache keyLocale in the URL; keep cookies out of public cache keys
CDN hit ratio falls after adding languagesVary: Accept-Language or Vary: Cookie on public pagesLocale in the URL; normalize at the edge if you can't
Users can't reach the English pageAutomatic redirect by Accept-Language or IP on every pageDetect only at /; suggest with a banner elsewhere
Root keeps switching back to the browser languageThe switcher doesn't save the choice, or detection ignores itCookie or account setting that wins over the header
Main bundle grew by hundreds of KBA static import of a catalog or a library's locale dataDynamic import per locale and route; size check in CI
Hydration warnings on datesServer and browser format with different time zones or localesPass locale and time zone explicitly
Cut-off buttons in GermanFixed widths sized for EnglishSize 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 and apple.com/ca/fr/ for French in Canada, because prices, products, and legal text differ by country.
  • Google Search used country domains like google.fr and google.de for years. In 2025 it began redirecting them to google.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-Language on requests whose path has no locale and redirects to /[lang]/.... Pages live under an app/[lang] folder and are rendered per locale, with generateStaticParams listing 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.

URL strategy
Detection
URL strategy /fr/ path with detection Redirect every page by Accept-Language. CDN hit ratio for HTML: about 33%. Search: Crawlers get redirected; other languages drop out. Users: Can't open another language; shared links bounce. 3 problems.CDN hits33%searchCrawlers get redirected; other languages drop outusersCan't open another language; shared links bounce3 problems to fix
A simple model with fixed numbers, not a measurement. Notice which choices cost cache hits, which cost search, and which cost users: a redirect on every page costs all three.

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

  1. URLs. "Each language gets its own URL, /fr/..., because the CDN, crawlers, and shared links all key on the URL."
  2. Detection. "Only the root detects: saved choice, then Accept-Language, then default, with a 307. No redirects on locale URLs, no geo-IP for language, a switcher on every page."
  3. SEO. "Server-rendered per locale, self-canonical, reciprocal hreflang with x-default, generated from one route map."
  4. Caching. "Locale URLs are cached at the CDN with no Vary on language; only the root redirect is private."
  5. Bytes. "One locale, split by route, messages compiled, Intl for formatting, fonts subset per script."
  6. Layout. "Components size to content, logical CSS properties for RTL, pseudo-localization in CI."

How deep to go by level

LevelWhat a strong answer shows
JuniorLocale in the URL, load only the active language, a language switcher, layouts that allow longer text
Mid-levelDetection order and why not geo-IP, temporary root redirect, hreflang basics, per-route catalogs, SSR per locale
SeniorCache key reasoning (Vary fragmentation, edge normalization), hreflang failure modes, build cost of SSG times locales, per-locale performance budgets and monitoring
StaffLanguage 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?

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 Vary on language.
  • Ship one locale and one route's strings, compile messages at build time, and use Intl instead 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?

Question 2

Your French pages don't show up in Google France, but the English ones do. Walk me through the debugging.

Question 3

Why not serve every language at the same URL and add Vary: Accept-Language?

Question 4

How do you support 30 languages without making the app slower?

Question 5

The German checkout button is cut off. How do you fix it now, and how do you stop it from happening again?

Practice it

These design problems lean on locales, routing, and seo.

References

Read next