Internationalizing a web app: locale routing, the lang attribute, and locale-aware formatting
Internationalizing a web app touches more layers than a mobile app does, because a web page has to work correctly for a browser, a search engine crawler, and a screen reader at the same time — three consumers that each read your locale signals differently. Most of the work is making sure those signals agree with each other and with what the user actually sees.
The lang attribute is not decorative
The lang attribute on the html element tells the browser, screen readers, and search engines what language the page's content is in. It affects things you might not expect: spell-check dictionaries, how a screen reader chooses pronunciation, and hyphenation rules. It should match the actual language rendered on the page, and it should update when the page's language changes — not stay fixed to whatever the app happened to be built in.
A common mistake is setting lang once in a root layout and never touching it again, so a page fully translated into another language still declares itself as the source language. Any tooling or assistive technology reading that attribute will trust it over the visible text.
<html lang="ja"> <!-- everything under here is announced and processed as Japanese --> </html>
Why the locale should live in the URL
There are three common ways to decide which language to show: a segment or subdomain in the URL, a cookie set from a past visit, and the browser's Accept-Language header. All three work for a human sitting at a browser, but only the URL-based approach gives you something a search engine can actually index.
A crawler generally doesn't carry cookies between visits and doesn't send a meaningful Accept-Language preference the way a real user's browser does, so a site that only branches on cookie or header will show the crawler one language every time — usually whatever the default is — no matter how many languages the site actually supports. Putting the locale in the URL, for example /ja/pricing versus /en/pricing, means each language version is a distinct, linkable, indexable page.
Cookie and header detection still have a role: they're a reasonable way to pick which locale to redirect a first-time visitor to. But once that decision is made, the app should land the user on a URL that encodes the choice, not just remember it silently.
hreflang tells search engines about the alternates
Once you have separate URLs per language, hreflang link tags tell a search engine that those URLs are alternate versions of the same page, so it can show a searcher the right one for their language and region rather than treating them as unrelated duplicate content.
<link rel="alternate" hreflang="en" href="https://example.com/en/pricing" /> <link rel="alternate" hreflang="ja" href="https://example.com/ja/pricing" /> <link rel="alternate" hreflang="x-default" href="https://example.com/pricing" />
Formatting dates and numbers with locale-aware APIs
Date and number formatting conventions vary by locale — the order of day, month, and year, the characters used for decimal and thousands separators, and how currency amounts are written. Hardcoding a format string bakes in one convention for every locale, which is usually wrong for most of them.
The standard approach is to use locale-aware formatting APIs and pass the current locale as a parameter, letting the platform apply the correct convention rather than building format strings by hand.
- Pass the active locale into every date/number/currency formatting call, never a hardcoded one
- Store dates and timestamps in an unambiguous format internally, and only localize at render time
- Don't assume comma-as-decimal or period-as-decimal; let the formatter decide per locale
Keep translatable text out of templates and code
The same discipline that applies to mobile apps applies here: translatable strings live in dedicated files keyed by an identifier, not inline in templates or scattered through component code. A template that concatenates fragments of a sentence around a variable — 'Welcome, ' + name + '!' — assumes the source language's word order, which frequently breaks once translated into a language with a different sentence structure.
The safer pattern passes the whole sentence, with the variable as a placeholder inside it, so a translator can reorder the sentence around the variable however their language requires.
// fragile: assumes word order
"Welcome, " + name + "!"
// robust: translator controls word order
t("welcome_message", { name })Where this leaves you
None of these pieces are optional add-ons to a working app; each one is read by a different consumer, and skipping any of them means that consumer sees a language mismatch — a crawler indexing the wrong page, a screen reader mispronouncing the page, or a date rendered in a format nobody in that locale would recognize.