The API contract is stable in codes. Human text follows the locale of the request or the user. UI: i18n-frontend / i18n-mobile. Contracts: api-design.
Non-negotiable rules
codemachine-readable (ORDER_NOT_FOUND).messageoptionally already localized or omitted — the client translates thecode. Do not change thecodeper language.- Locale: authenticated user profile > explicit query/header (
lang,X-Locale) >Accept-Language> default. Never geo-IP as the sole source. - Parse
Accept-Languagewith q-values and fallback (pt-BR→pt→en). - Dates in the API: ISO-8601 / epoch. Calendar formatting stays on the client, except for PDF/email generated on the server.
- Catalogs versioned in the repo (
locales/en.json,.ftl,.resx). No ad hoc translation SQL in a feature. - Logs and metrics in stable English/technical. Do not localize
slog/ILoggerfor ops. - Plurals/ICU on the server only when the server generates text (email, PDF, push).
HTTP
Accept-Language: pt-BR, pt;q=0.8, en;q=0.5
X-Locale: pt-BR
- Document which header wins. Locale cookie only if the web product already uses it.
Content-Languagein the response when the body is localized text.- Validation: field messages via catalog or codes (
validation.email). - gRPC: locale in metadata (
x-locale);grpc.
What the server translates
| Translate on the backend | Let the client translate |
|---|---|
| Email, SMS, push | Mobile/web app JSON |
| PDF / CSV for humans | code of API error |
| Server-side reports | UI labels |
If the API is only for your app, prefer codes + i18n on the client. If it is public for third parties, message in the requested locale.
Go
nicksnyder/go-i18n(or the repo's lib). JSON/TOML bundle per locale.- Gin middleware: parse locale →
c.Set("locale", tag)→localizer.MustLocalize. golang.org/x/text/languagefor match. Email templates: passlocalizer, do not concatenate.
APIs: golang-apis.
accept := c.GetHeader("Accept-Language")
tag, _ := language.MatchStrings(matcher, accept)
msg := localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "order.not_found"})
Rust
- Fluent (
fluent-templates/i18n-embed) for rich text;rust-i18nif the crate already uses it. - Locale in the
Extension/ Axum extractor..ftlfiles per language. unic-langid. Do not format dates withformat!without locale.
HTTP: axum. Desktop egui: load Fluent in the App and resolve in the update — do not hardcode menu labels.
C# / ASP.NET Core
IStringLocalizer<T>/IViewLocalizer. Resources.resxor JSON providers.RequestLocalizationOptions:SetDefaultCulture,AddSupportedCultures, providers (QueryString,Cookie,AcceptLanguageHeader).- ProblemDetails:
titlelocalized if the API is for humans;type/codestable. CultureInfoin the request (RequestCulture).CultureInfo.InvariantCulturefor persistence/ISO parse.
Host: aspnet-core.
NestJS / TypeScript
nestjs-i18n(or equivalent).I18nContext/I18nService. JSON per locale ini18n/.- Resolver: header → query → cookie. Fallback to config.
- Exception filter: maps
code→i18n.t('errors.' + code). - DTOs: messages from
class-validatorviai18nValidationMessage.
HTTP: nestjs-apis.
Email and jobs
- Asynchronous job: store the locale in the payload (the user's). Do not use the worker's locale.
- Templates:
react-emailwith a dictionary for the same locale.langin the HTML. - Unsubscribe / legal: account language.
Anti-patterns
message: err.Error()in pt or en depending on the pod- Translate
code - Concatenate sentences in the service (
"Order " + id + " not found"for the user) time.Format("02/01/2006")without locale in email- A
map[string]map[string]stringgiant in the handler - Accept only
lang=ptand breakpt-BR
Integration with other skills
- Contracts:
api-design(code+message) - Go:
golang-apis - Rust HTTP:
axum - C#:
aspnet-core - Nest:
nestjs-apis - Clients:
i18n-frontend/i18n-mobile - Email:
react-email - gRPC metadata:
grpc
Definition of done
- Locale resolved in the documented order
- Errors with
codestable; human text via catalog or client - Catalogs in the repo; ICU/plural where the server generates prose
- Jobs/emails with persisted locale
- ISO dates in the JSON API