The i18n/localization decision record: why the locale detection strategy you chose determines your format correctness surface and your translation management debt

Localization decisions are made during three sessions that never document the consequences — the "add German support" session that adds a locale config entry without specifying that every date-formatting call must use the locale parameter, the "add currency display" session that stores UI locale and billing currency as independent fields without specifying the format locale derivation rule, and the "manage translation files" session that creates a key-value JSON structure without specifying plural forms, interpolation escaping, or the missing key policy. What none of these sessions produce is the locale resolution precedence chain, the UI locale vs. format locale split specification, or the server-side locale propagation contract.

A 16-person B2B SaaS built a project management tool, launched in the US, and acquired a German enterprise customer in month eight. The "Add German language support" session added de-DE to the i18n library's locale list, imported the library's German translation bundle, and configured the frontend to render navigation labels, button text, and section headings in German. The session closed with the engineer satisfied that the interface displayed correctly in German.

The customer's legal team reviewed the tool's auto-generated contract summary documents as part of their onboarding. The documents displayed the contract renewal date as "07/31/2026." German date convention is DD.MM.YYYY — "31.07.2026." The legal team could not determine whether "07/31" was an American date (July 31, 2026) or an attempt at the German format where the system could not represent the 7th day of the 31st month. The ambiguity was not pedantic: German contract law requires dates to be unambiguous, and a renewal date that could be interpreted as two different dates created a legal question about enforceability. The legal team put the contract on hold pending written clarification from the startup about the date's intended meaning.

The engineering team investigated and found six components using date-fns/format with the hardcoded format string 'MM/dd/yyyy'. The session that added German support had modified the i18n config but had not documented that format('MM/dd/yyyy') is locale-unaware — it always produces American month-first output regardless of the active locale. The locale-aware approach requires importing the German locale object from date-fns/locale/de and passing it explicitly to format, or using a locale-context-aware wrapper that reads the active locale from a context provider. Neither approach was specified in the session that added de-DE, and the six non-compliant components were not surfaced by any automated test — because the test suite ran in the en-US locale and produced correct output regardless of whether the locale parameter was passed.

The fix took three hours: import the German date-fns locale, pass it to each of the six format calls, add a locale-specific date rendering test. But the legal hold introduced a two-week delay in the customer's contract onboarding. The founding session that added German language support had not documented the format correctness contract: every component that produces locale-specific output must use the locale-aware API with the active locale explicitly passed, not a hardcoded format string; the complete list of format operations in the codebase is tracked in the ADR so code review can verify compliance before the next locale is added.

A 22-person B2B marketplace added multi-currency display for European customers. The "Internationalize the checkout" session added Intl.NumberFormat with style: 'currency', stored the user's preferred currency in their billing profile as a separate billing_profile.currency field (defaulting to the currency of the user's country at signup), and initialized the formatter as new Intl.NumberFormat(navigator.language, { style: 'currency', currency: billingProfile.currency }). The checkout correctly displayed "€2,000.00" for a user with navigator.language = 'de-DE' and EUR billing currency, and "$2,000.00" for a user with navigator.language = 'en-US' and USD billing currency. The session produced a demo that worked correctly for the two test cases the engineer had considered.

The flaw surfaced three months later when a German-headquartered startup with an English-speaking engineering team set their interface language to English (en-US) but their billing currency to EUR. The checkout displayed "€2,000.00" — formatted with US conventions: period as decimal separator, comma as thousands separator, currency symbol before the amount. The German accounting department who reviewed purchase approvals for this company expected "2.000,00 €" — period as thousands separator, comma as decimal separator, symbol after the amount, which is the convention for EUR amounts in German-speaking countries. An invoice generated from the checkout showed "€ 2,000.00." The accounting team flagged it as potentially malformed: German invoice regulations require amounts to be displayed according to the conventions in use at the seller's registered country, and the format did not match. They rejected the invoice and requested a corrected version.

More critically, the PDF invoice generation service was running in Node.js without locale awareness. The session that internationalized the checkout had not documented that navigator.language is a browser API unavailable in Node.js — the PDF service used a hardcoded en-US locale for all Intl.NumberFormat calls because the engineer who built it worked before the checkout internationalization session and the session that added the checkout formatter had not updated the service. Every PDF invoice issued — for German, French, Dutch, and Japanese customers — was formatted with US conventions. Three German customers who paid based on the checkout display contacted support to report that their PDF invoice and their browser checkout showed different amounts — not because the numeric value differed, but because "2.000,00 €" and "€2,000.00" read as different numbers to someone whose reference format is European.

The founding session that added Intl.NumberFormat to the checkout had not documented the relationship between the user's UI locale (the language displayed in the interface) and the format locale (the regional conventions applied to numbers, dates, and currencies). It had not documented that server-side rendering cannot read navigator.language and must receive the format locale through the request chain. These gaps produced a system that was correct for the demo's two test cases — the English user and the German user who happened to want German formatting — and incorrect for any user whose UI language preference did not match their regional formatting convention, and for any server-side operation that produced formatted output.

Structural properties set by the i18n/localization decision

Three structural properties are determined when a team decides how to implement internationalization in their product. None appear explicitly in the founding session that added the first non-English locale or the first currency formatter — they are the operational consequences of choices made under the pressure of demonstrating that the product works for the first international customer.

Property 1: The locale resolution model and the format correctness surface. The locale resolution model determines which locale value is used for number, date, currency, and sort order formatting at each rendering surface in the system. There are four sources of locale information with different availability and reliability characteristics: the Accept-Language HTTP header (sent automatically with every request, available on the server, contains a ranked preference list rather than a single resolved value — parsing it requires matching against supported locales and choosing the best match), the browser's navigator.language (a single resolved string, available only in browser JavaScript, not available in Node.js server-side rendering or any non-browser runtime), a stored user preference (an explicit locale field in the user profile database, available anywhere with a database lookup, requires the user to have made an explicit locale selection), and a hardcoded application default (always available, always wrong for users whose locale differs from the default).

The locale resolution precedence chain documents which source takes priority and what the fallback sequence is when a higher-priority source is unavailable or does not match a supported locale. A correct precedence chain: stored user preference (if set) → Accept-Language first match against supported locales (if the header is present and a match exists) → application default. The authentication strategy decision record documents the session model; the locale resolution model must integrate with the session model to document whether the resolved locale is stored in the session cookie (so subsequent requests do not re-parse the Accept-Language header), in the authenticated user record (so the locale is consistent across sessions and devices), or resolved fresh on every request (stateless, but adds Accept-Language parsing to every request's critical path).

The format correctness surface is the complete enumeration of operations in the codebase that produce locale-specific output. It includes date formatting (short date, long date, date with time, relative time), number formatting (integers, decimals, percentages), currency formatting (symbol position, decimal precision, thousands grouping, negative amount representation), list formatting ("A, B, and C" in English vs. "A, B und C" in German), ordinal formatting ("1st" vs. "1."), and sort order (locale-sensitive collation for any UI list containing text). The ADR must enumerate the format operations in use and document the locale-aware API used for each, because any operation using a non-locale-aware API — a hardcoded date-fns format string, a direct Number.toFixed(2) without Intl.NumberFormat, a Array.prototype.sort without a locale-sensitive comparator — silently bypasses the resolution chain and always produces the default-locale output. The format correctness surface is the audit surface for code review: when a new locale is added, every item on this list must be verified as locale-aware before the locale goes to production.

Property 2: The UI locale vs. format locale split and the localization scope. The UI locale determines which language translations are applied to the interface — button labels, headings, error messages, navigation items. The format locale determines which regional conventions govern number formatting, date formatting, currency display, and sort order. In the majority of users, these resolve to the same identifier: a German user wants the German interface (de-DE) and German formatting conventions (period thousands separator, comma decimal separator, currency symbol after amount). For a non-trivial population in international teams, enterprise software, and professional tools, they diverge.

A Japanese engineer at a US company may want the English interface but Japanese date formatting (YYYY/MM/DD, the ISO-like format common in Japanese technical documents). A Brazilian developer at a European company may prefer Portuguese UI but need EUR formatting for their expense reports. A German-headquartered startup with a globally distributed team may want the English interface for accessibility across the team but German accounting number format for documents reviewed by their German CFO. A system that uses a single locale field for both UI language and number formatting produces incorrect output for any user whose language preference does not match their regional format preference — and for enterprise B2B products that sell to multinational companies, this is not an edge case but a steady-state condition.

The ADR must document whether the system supports separate UI locale and format locale fields. If yes: how each is represented in the user profile schema, which field is used for which operation (translations use UI locale; Intl.NumberFormat, Intl.DateTimeFormat, Intl.Collator use format locale), what the fallback rule is when format locale is not explicitly set (default to UI locale, default to the application locale, require the user to set both), and how the two fields are presented in the settings UI so users can understand and adjust them independently. If no (single field): the decision rationale (user base is homogeneous enough that diverging preferences are not worth the added complexity) and the consequences (users who need diverging preferences will receive incorrect formatting and have no mechanism to fix it without switching their UI language to a different value).

The database vendor decision record documents the user profile schema; the i18n ADR must specify the exact column names and data types for locale storage, the validation constraint (a curated enum of supported BCP 47 locale tags, not a free-form string that could accept arbitrary and unsupported values), and whether locale is stored at the user level, the organization level (all users in an organization share a format locale set by the admin), or both with a per-user override.

Property 3: The server-side locale propagation and the hydration correctness surface. Any server-side operation that produces locale-specific output — HTML server-side rendering, PDF invoice generation, email template rendering, API responses with pre-formatted date strings, CSV exports with formatted numbers — cannot access navigator.language. The server must receive the user's locale explicitly through one of three mechanisms: a session cookie (the resolved locale is stored in the session when it is first determined and read from the cookie on each subsequent request), a request header (the client sends the resolved locale as a header on each API request — either the standard Accept-Language header with a single resolved value, or a custom header like X-User-Locale), or a database lookup (for server-side operations that are not associated with an active HTTP request, such as a background job generating a weekly report PDF or a triggered email, the locale is retrieved from the user record using the user ID).

The hydration correctness surface is the set of server-rendered outputs that must produce locale-identical results to what the client would render for the same user. For server-side rendered HTML, a mismatch between the server's locale and the client's locale produces a hydration mismatch warning in React and similar frameworks, and a visible flash of incorrect content if the client rehydrates to a different locale than the server used. For PDF invoices, an email template, or an API response body that contains a pre-formatted date string (rather than an ISO timestamp the client would format), a locale mismatch produces an output the user sees but cannot override — they cannot tell the PDF to reformat its dates. The frontend framework decision record documents the SSR model; the i18n ADR must document how the server's locale is guaranteed to match the client's locale for SSR outputs, specifically: where the server reads the locale for the initial render, how the client passes its resolved locale to the server in the hydration payload, and what happens on the server when the locale from the session cookie does not match the locale in the client's hydration payload.

The API gateway decision record documents the request routing and header propagation; the i18n ADR must document whether the gateway normalizes or strips the Accept-Language header before passing requests to backend services. A gateway that strips Accept-Language to simplify service authentication prevents backend services from resolving locale from the header even when they want to, forcing all services to depend on the session cookie or database lookup mechanism. A gateway that passes Accept-Language unmodified allows each service to resolve locale independently, which produces correct results only when all services implement the same resolution chain — divergent implementations produce inconsistent output across services for the same user.

What the founding session records and what it omits

The i18n decision is almost always made in multiple phases: an initial phase when the first non-English locale is added (driven by a specific customer request or a market expansion), a second phase when currency display is added (driven by pricing in multiple currencies), and a third phase when the translation management burden becomes visible (driven by translation files growing large enough that ad-hoc edits start breaking things). None of these phases produces an i18n ADR. Each phase produces a working implementation for the specific scenario that motivated it, leaving the broader contract — locale resolution, format locale, server-side propagation, plural forms, missing key behavior — undocumented and discoverable only through the next failure mode.

Three types of AI chat sessions generate these gaps:

The "how do we add [language] to our app?" session. The engineer is adding support for the first non-English locale and asks how to set up an i18n library, add a locale config entry, import a translation bundle, and implement a language switcher. The session explains the i18n library setup, produces the locale config, and walks through rendering translated strings using the library's translation hook. What the session does not ask or answer: which operations in the codebase currently produce locale-specific output without a locale parameter — date-fns format calls with hardcoded format strings, Number.toFixed(2) instead of Intl.NumberFormat, new Date().toLocaleDateString() without a locale argument — and how those must be changed before the locale goes to production; what the locale resolution precedence chain is for users who have not explicitly set a language preference; what the fallback behavior is when a translation key exists in the default locale but not in the new locale; what the procedure is for adding locale support to parts of the application that render on the server (PDF generator, email templates) rather than in the browser; and what the list of currently supported locales is and who can add a new locale in the future without breaking existing users. The observability strategy decision record documents the monitoring infrastructure; missing translation key events must be tracked as application metrics — each key that falls back to the default locale is a signal that the translation bundle is incomplete, and the missing key rate per locale is the primary health metric for translation coverage that the session that adds the locale never specifies should exist.

The "how do we display currencies for international customers?" session. The engineer is adding multi-currency support to the checkout and asks how to format currency amounts correctly for different locales. The session recommends Intl.NumberFormat with the style: 'currency' option, explains the currency parameter (the ISO 4217 currency code), and shows an example of formatting EUR amounts with a German locale. What the session does not ask or answer: how the format locale is determined when the user's UI locale and their billing currency imply different formatting conventions — what should Intl.NumberFormat receive as its locale argument for a user who wants the English interface but EUR currency; how server-side operations (PDF generation, email templates, CSV exports, API responses) receive the format locale, given that navigator.language is unavailable in server-side JavaScript; what the canonical representation of negative currency amounts is across supported locales (does "-€100.00" mean the same thing as "(€100.00)" in all accounting contexts, and does this affect how refund amounts are displayed on invoices); how zero amounts are formatted (does "€0.00" or "€-" represent a zero balance, and is this consistent across server-side and client-side rendering); and what the source of truth for the user's format locale is — the navigator.language value at render time, the stored user preference, or the currency's canonical country locale. The caching strategy decision record documents the HTTP caching model; any API response or server-rendered page that includes locale-specific formatted output must set a Vary: Accept-Language header (or equivalent) to prevent a cached response in one locale from being served to a user with a different locale preference, because a CDN or reverse proxy that caches locale-specific HTML will serve the wrong locale to subsequent users if the cache key does not include the locale.

The "how do we manage translation files as we scale?" session. The engineering team has accumulated translation files for multiple locales, engineers are editing them directly in pull requests, and merge conflicts are frequent. The engineer asks how to organize translation files for maintainability. The session recommends namespaced JSON files (one file per feature namespace per locale, rather than one giant file per locale), explains the import model for loading namespaces on demand, and shows the file structure. What the session does not ask or answer: how plural forms are handled for locales with more than two plural categories — English has two (one item / two items), but Russian has four (1 item, 2–4 items, 5–20 items, 21 items following the 1 pattern), Arabic has six, and a translation system that provides only a singular and plural key will produce grammatically incorrect output in Russian and Arabic for every count value that is not 1 or greater-than-1; how interpolated values in translation strings are escaped — if a translated string can contain an HTML anchor (Read more <a href=...>here</a>), who is responsible for ensuring the URL in the translation file is not a malicious external link, and how the i18n library renders this without XSS risk; what the copy-change workflow is when a translation key needs to be renamed — a key rename requires a migration step (keeping the old key until all deployed instances are updated) or a coordinated deploy, and the session that sets up the file structure is the correct time to document the rename policy, not after the first rename breaks a deployed locale; and what the missing key behavior is when a key exists in the default locale but not in the target locale (the i18n library's default behavior is often to display the raw key name, which surfaces untranslated keys as identifiers like dashboard.sidebar.recentItems visible to end users). The error handling strategy decision record documents the API error response format; localized error messages returned by the API must follow the same locale propagation contract as any other locale-specific output, which means the server must receive the user's locale in the request (through Accept-Language or a session cookie) and use it to select the correct error message translation, not default to the application locale and rely on the client to re-translate the error code.

The WhyChose extractor surfaces the "add German support" session, the "internationalize the checkout" session, and the "organize our translation files" session from AI chat history. The i18n ADR converts the implicit choices in those sessions — navigator.language for locale detection, a single locale field shared by UI and formatting, a JSON file per locale — into a documented locale resolution precedence chain that specifies what happens when the user has not set a preference, a documented UI locale vs. format locale split with a fallback rule, and a documented server-side propagation contract that prevents the PDF invoice service from defaulting to en-US for every customer regardless of their country.

The five sections of an i18n/localization ADR

Section 1: Locale resolution model and precedence chain. Document the locale resolution precedence chain as an ordered list of sources with explicit fallback conditions. The recommended chain for most B2B products: (1) stored user preference in the authenticated user record — checked first because it reflects an explicit user decision, is consistent across sessions and devices, and does not require re-parsing on every request; (2) stored organization preference in the organization record — for B2B products where the admin sets a default locale for all users, the organization preference applies before the per-user default for users who have not set their own preference; (3) first match of the Accept-Language request header against the application's list of supported locale tags — a correct implementation uses BCP 47 language tag matching (RFC 4647 lookup matching, not a simple string equality check) to handle the case where the browser sends de-CH (Swiss German) and the application supports de-DE but not de-CH, and should match de-DE as the best available match; (4) the application's configured default locale — used only when no source above produced a supported locale.

Document the locale validation step: after resolving a locale from any source, validate it against the application's supported locale list before using it. An unsupported locale tag — whether from a malformed cookie, a user who entered a custom value in a settings field without a dropdown, or an Accept-Language header from an unusual browser configuration — must fall back to the application default rather than being passed to Intl.DateTimeFormat or Intl.NumberFormat, both of which throw a RangeError in some JavaScript environments and silently fall back to the system locale in others. The locale validation is a single-point check at the resolution layer; subsequent operations receive a guaranteed-valid locale and need not re-validate. The API schema design decision record documents the API request and response format; document whether the API accepts a locale parameter in requests (for cases where the client wants to request a specific locale for the response, rather than relying on Accept-Language), what the parameter name and format are, and how a locale parameter conflicts with the session-cookie locale (explicit parameter takes precedence over cookie, cookie takes precedence over Accept-Language).

Document the locale storage mechanism. If the resolved locale is stored in the session cookie: document the cookie name, the SameSite and Secure attributes, and the TTL. If the locale is stored in the user record: document the database column name, the supported values constraint (an enum of BCP 47 tags, not a free-form string), the default value for new users, and the migration path for existing rows when a new locale is added (add the locale to the enum without changing existing rows — they retain their stored value; remove a locale by migrating existing users to the fallback locale before removing the enum value).

Section 2: UI locale vs. format locale split and regional format override. Document the decision on whether to maintain separate UI locale and format locale fields, with an explicit rationale based on the user base. If separate fields: document the column names (users.ui_locale and users.format_locale, for example), the fallback rule when format_locale is not set (default to ui_locale is the common correct choice, because the majority of users have matching preferences, and the minority who need to diverge can set format_locale explicitly), how the settings UI presents the two fields (a single "Language" dropdown that sets ui_locale and a separate "Number and date format" dropdown that sets format_locale and shows options like "German (2.000,00 €)", "American (2,000.00 $)", "ISO (2000.00 USD)"), and the complete mapping of format locale values to the specific Intl.NumberFormat and Intl.DateTimeFormat options each implies.

Document the format correctness contract explicitly as an enumerated list of format operations and their required API: Short date — use Intl.DateTimeFormat(formatLocale, { dateStyle: 'short' }), not date-fns/format with a hardcoded format string; Long date — use Intl.DateTimeFormat(formatLocale, { dateStyle: 'long' }); Date with time — use Intl.DateTimeFormat(formatLocale, { dateStyle: 'short', timeStyle: 'short' }); Relative time — use Intl.RelativeTimeFormat(formatLocale) or an i18n library's relative time function that accepts the format locale; Decimal number — use Intl.NumberFormat(formatLocale), not Number.toFixed() or toLocaleString() without a locale argument; Currency — use Intl.NumberFormat(formatLocale, { style: 'currency', currency: currencyCode }) where formatLocale is the user's format locale, not navigator.language or a hardcoded value; List — use Intl.ListFormat(uiLocale) for natural-language lists in UI text, uiLocale (not format locale) because this is a linguistic property of the UI language, not a regional format convention; Sort — use Array.prototype.sort with an Intl.Collator(uiLocale).compare comparator for any user-visible sort of text content, because sort order is a linguistic property of the UI language.

The format correctness contract is the document that code review uses to evaluate pull requests that add new formatting operations. Every new format call must use the locale-aware API from the contract list; any call that uses a hardcoded format string or the no-locale-argument form of a formatting function must be flagged as a format correctness violation before merge. Document the linting rule that enforces this: a custom ESLint rule that flags format( calls from date-fns without the locale option, and toFixed( calls on numbers that appear to represent currency amounts, is worth the one-time implementation cost.

Section 3: Translation key architecture and plural forms specification. Document the file organization model: namespaced JSON files (one file per feature namespace per locale, loaded on demand) vs. a single file per locale (simpler to reason about, slower to load as the file grows). Document the key naming convention: flat dot-notation within a namespace (dashboard.recentItems.emptyState), nested objects in JSON, or a hybrid. Document the key rename policy: keys are never renamed without a migration step; the migration step is (1) add the new key with the correct translation in all locales, (2) update all references in the codebase from old key to new key in a single pull request, (3) keep the old key in the translation files for one full deploy cycle (to ensure in-flight sessions using cached bundles can still render), (4) remove the old key in a follow-up pull request. Renaming a key without a migration step breaks any deployed instance that has cached the old translation bundle, because the old bundle still references the old key name that no longer exists in the current codebase's calls.

Document the plural forms specification for each supported locale using Unicode CLDR plural category names. English requires two categories: one (singular: "1 item") and other (plural: "0 items", "2 items", "100 items"). German also requires two: one ("1 Element") and other ("0 Elemente", "2 Elemente"). French has a nuance: one covers 0 and 1 ("0 article", "1 article") and other covers 2+. Russian requires four categories: one (ends in 1 but not 11: "21 элемент"), few (ends in 2–4 but not 12–14: "22 элемента"), many (ends in 0, 5–9, or 11–20: "5 элементов"), other (decimals). Arabic requires six categories: zero, one, two, few, many, other. A translation key that stores only singular and plural variants will produce grammatically incorrect output in Russian for every count value other than 1 and values falling in the other category, because the Russian translation file has no way to express the few and many forms. The i18n library's plural API must use Unicode CLDR plural rules (most mature i18n libraries support this via Intl.PluralRules), and the translation file schema for count-bearing strings must include all required plural categories for each locale, not just singular and plural. The ADR must document the plural form requirements per supported locale and the validation step in the CI pipeline that rejects a translation pull request that is missing a required plural category for any supported locale.

Document the interpolation escaping policy. Translation strings that contain interpolated values — user names, counts, URLs, formatted numbers — must specify which types of values can be interpolated and how they are escaped. If translated strings can contain HTML markup (a link element, a bold span, a code tag for an inline variable name), the i18n library must render these safely: either by using a whitelist of allowed HTML tags and attributes and escaping everything else, or by using a component interpolation API that accepts React/Vue components rather than raw HTML strings, so the translation string never contains raw HTML that could be injected. A translation file that contains "readMoreLink": "Read more <a href='{{url}}'>here</a>" is an XSS surface if the URL is not validated — a malicious translation file or a compromised translation service could inject a javascript: URL. Document the escaping policy as a constraint on translation file content: URLs in translation strings must be static (not interpolated), HTML links must use a component interpolation API, and free-form user-controlled strings interpolated into translations must be treated as plaintext and escaped by the i18n library's interpolation engine. The data pipeline orchestration decision record documents the batch processing infrastructure; for any pipeline that produces locale-specific reports, exports, or documents (CSV exports with formatted numbers, PDF reports with translated column headers, email digests with localized date ranges), the pipeline must receive the format locale and UI locale from the triggering request or user record, not default to the pipeline worker's system locale.

Section 4: Server-side locale propagation contract. Document the locale propagation mechanism for each category of server-side operation. For synchronous HTTP handlers (server-side rendered HTML pages, API endpoints that return pre-formatted strings): the locale is read from the session cookie (set at login or on first request for anonymous users), validated against the supported locale list, and passed explicitly to all locale-dependent operations. The locale is not read from navigator.language (unavailable on the server) or from an environment variable (which would be a deployment-level constant, not a per-user value). For asynchronous HTTP handlers that trigger background work (a request that enqueues a PDF generation job): the resolved locale must be serialized into the job payload at enqueue time — the background worker cannot read the locale from the HTTP request because the request has completed by the time the worker runs. For background jobs without a triggering HTTP request (a scheduled weekly digest email): the locale must be retrieved from the user record by the job, using the user ID as the lookup key.

Document the SSR locale matching requirement for frameworks that perform server-side rendering with client-side hydration. The server must resolve the locale using the session cookie and pass it to the i18n library's server-side initialization. The same locale must be serialized into the client-side hydration payload (typically as a global variable on the window object or as a property of the serialized store state). The client hydration must use the locale from the hydration payload, not re-resolve it from navigator.language — a re-resolution would produce a mismatch whenever the user's stored locale differs from their current browser's language setting, causing a hydration warning and a visible flash of re-rendered content in the locale that the browser reports. The matching requirement is expressed as a test: a test that server-renders with a de-DE user locale and verifies that the hydration payload contains de-DE, and that a client initialized with the hydration payload produces identical locale-specific output to the server-rendered HTML.

Document the locale validation at the server's trust boundary. The session cookie locale value is set by the server and should be treated as trusted (it was validated when written). An Accept-Language header is set by the client and is untrusted — it must be validated against the supported locale list before use, and an invalid or unsupported value must fall back to the application default rather than being passed to the i18n library. Document the response header for locale-specific cached content: any server response that contains locale-specific output must include Vary: Accept-Language (or the equivalent custom locale header) so that HTTP caches — CDN edge caches, Varnish, Nginx proxy caches — include the locale in the cache key. Omitting the Vary header means a CDN may cache a German-locale page and serve it to a French-locale user on the next request. The caching strategy decision record documents the CDN and cache configuration; the locale-specific Vary header requirement must be added to the CDN configuration as a constraint — it increases the cache key space and reduces cache hit rate for locale-specific content, which is the correct trade-off, and must be documented as the chosen trade-off rather than discovered when the first cache-locale collision is reported by a user.

Section 5: Translation coverage monitoring and missing key policy. Document the missing key policy: when the application encounters a translation key that exists in the default locale but not in the current user's locale, what is rendered. The options are: display the raw key name (dashboard.recentItems.emptyState, which is visible to the end user and looks broken), display the default locale value (fall back to English, which is legible but may confuse users who set a non-English locale and now see a mixture of their locale and English), display a placeholder string (an empty string, a dash, or a generic "[Missing translation]" message that signals the gap without rendering the key name), or throw an error (appropriate only in development and CI, not in production). Document the policy for each environment: development — throw an error or log a warning to the console so developers notice missing keys immediately; CI — fail the build if any translation key used in the current code is missing from any supported locale's translation file; production — fall back to the default locale value with a logged error event so the missing key is tracked but the user sees legible content.

Document the translation coverage metrics: the ratio of keys present in each non-default locale to the total keys in the default locale, tracked per locale and per namespace. A coverage threshold of 95% is a reasonable minimum before a locale is enabled in production — below this threshold, a substantial fraction of the UI renders in the default locale for users of the partially-translated locale, which is often worse than not offering the locale at all. The coverage metric is computed in CI as part of the translation validation step: count the keys in each locale file, compute the ratio against the default locale file, reject the pull request if any locale's coverage falls below the threshold. The missing key rate in production — the count of missing key log events per locale per day — is the operational signal that translation coverage is degrading as new features are added without corresponding translations. The observability strategy decision record documents the metrics and alerting infrastructure; missing key rate must be tracked as an application metric with an alert threshold (more than N missing key events per day per locale should trigger a review of which keys are missing and whether the locale's translation bundle needs an update before the next release).

Document the translation update workflow: how new translation keys reach the translation team, how the team returns completed translations, and how the translations are merged into the codebase. For a startup that translates internally, the workflow is: engineer adds a new key with the English value and a TODO: translate marker in all other locale files, opens a pull request that is required to merge without translation (the CI missing-key check allows TODO: translate as a placeholder that does not fail the build but increments the coverage deficit counter), a follow-up pull request adds the translations within N days of the original merge. Document the N-day window explicitly — it is the maximum time a new UI element can be visible to non-English users in the default locale before the translation is required. Without an explicit window, translation follow-ups accumulate indefinitely and coverage degrades until a user files a support ticket about the untranslated element.

None of these monitoring surfaces — missing key rate, per-locale coverage ratio, N-day translation window, CI coverage threshold — are configured in the founding session that adds the first non-English locale. The founding session produces a working translation system for the specific scenario that motivated it: the German customer who needed German headings and button labels. The i18n ADR produces the operational model that covers the full range of format operations, server-side rendering contexts, plural form complexity, and translation management discipline — so the next engineer who asks "why are German customers seeing American date format in their PDF invoices?" has the locale propagation contract documented, not just the answer to "why didn't we support German?" that would have been the question before the founding session.

FAQs

What is the difference between UI locale and format locale, and should they be the same field?

The UI locale determines which language translations are applied to the interface — button labels, headings, error messages, navigation items. The format locale determines which regional conventions govern number formatting (decimal separator, thousands grouping), date formatting (day-month-year vs. month-day-year vs. year-month-day, separator characters), currency display (symbol position, negative amount representation), and sort order. In most users, these resolve to the same identifier: a German user wants the German interface (de-DE) and German formatting conventions. For international teams, expats, and bilingual professionals, they diverge: a German startup with an English-speaking team may want the English interface and German accounting number format for documents reviewed by their CFO.

Whether to store them as a single field or two depends on the user base. For a B2B product sold to multinational companies, separate fields are worth the additional schema and UI complexity — the payoff is that every user's documents are formatted correctly for their accounting department without requiring them to switch their interface language. For a consumer product or a product with a homogeneous domestic user base, a single field is likely sufficient. The decision must be made in the i18n ADR rather than discovered when the first customer's accounting department rejects an invoice formatted in the wrong convention. If a single field is chosen, document the specific known case where it produces incorrect output (a user whose language preference and regional format convention differ) and the workaround available to those users (none, or a manual override in advanced settings).

How should server-side rendering receive the user's locale?

navigator.language is a browser API — it is unavailable in Node.js and any other server-side JavaScript runtime. A server-side operation (HTML rendering, PDF generation, email delivery, CSV export) that uses navigator.language will throw a ReferenceError or silently receive undefined and fall back to the system locale, producing the application default regardless of the user's locale preference. There are three correct propagation mechanisms, each suited to a different operation category.

For synchronous HTTP handlers, the resolved locale is stored in the session cookie when first determined (at login, or at first request for anonymous users after resolving from Accept-Language). The server reads the cookie on each request and passes the locale to all locale-dependent operations. For background jobs with a triggering request context — a PDF generated immediately in response to an API request — the locale must be serialized from the request context into the job payload before the handler returns, because the background worker runs after the request has completed and cannot read the request's session cookie. For fully asynchronous background jobs — weekly digest emails, scheduled reports — the locale must be retrieved from the user record in the database using the user ID, because there is no request context to carry it. Document all three mechanisms in the i18n ADR with explicit examples of which server-side operations fall into each category.

What should an i18n ADR document that a general "we support multiple languages" product decision does not?

A general product decision records which locales will be supported, which features will be translated, and the target launch date for each locale. An i18n ADR must document five structural properties. (1) The locale resolution precedence chain: the ordered list of sources (stored user preference, organization preference, Accept-Language header match, application default) and the fallback behavior when each source is unavailable or does not match a supported locale. (2) The format correctness contract: the enumerated list of format operations in use (date short, date long, number, currency, relative time, list, sort) and the locale-aware API required for each — the document that code review uses to evaluate every new formatting call for compliance. (3) The UI locale vs. format locale model: whether a single field or two fields, which is used for which operation, and the fallback when format locale is not set. (4) The server-side locale propagation contract: the mechanism for each category of server-side operation (session cookie, job payload, database lookup) and the SSR locale matching requirement that prevents hydration mismatches. (5) The translation key architecture: the file organization, the plural forms specification per supported locale using Unicode CLDR categories, the interpolation escaping policy, the missing key behavior per environment, the N-day translation window, and the CI coverage threshold.

None of these appear in the session that adds the first non-English locale. All of them determine whether the localization system produces correct output across the full range of users, server-side operations, and plural forms — or whether it produces correct output in the English-user demo and incorrect output in the German accounting department's invoice review, the Russian user's count-bearing UI, and the PDF invoice service that defaults to en-US for every customer regardless of their country.