fullstackhero ships localized end-to-end: the backend and both React apps translate their user-facing strings, and the two sides agree on the active language for every request. Two cultures are included out of the box: en-US (the default and fallback) and pt-BR. Adding another language is a small, well-defined change on each side, described at the bottom of this page.
The active language is resolved per request from a well-defined chain, persisted per user, and carried on the JWT, so a signed-in user sees the same language in the UI and in server-produced messages (validation errors, problem details) regardless of which browser they use.
Backend
String resources
Localizable backend strings use the framework’s IStringLocalizer. Catalogs are split in two tiers, each a marker type with its resx files next to it:
- Shared -
SharedResourcesin the Core building block, underBuildingBlocks/Core/Localization/: ProblemDetails titles, cross-cutting errors and shared validation messages. - Per module - one
{Module}Resourcescatalog per module, undersrc/Modules/{Module}/Modules.{Module}/Localization/(AuditingResources,BillingResources,CatalogResources,ChatResources,FilesResources,IdentityResources,MultitenancyResources,NotificationsResources,TicketsResources,WebhooksResources): the domain messages that module owns.
Every catalog follows the same naming, named for specific cultures:
| File | Culture | Role |
|---|---|---|
SharedResources.resx | neutral (invariant) | English (en-US) source, and the fallback for any unresolved key |
SharedResources.pt-BR.resx | pt-BR | Brazilian Portuguese |
The supported cultures are the specific tags in SupportedCultures.Tags (en-US and pt-BR), a code-level list in BuildingBlocks/Core/Localization/SupportedCultures.cs with no neutral entries. Naming the catalogs for pt-BR rather than the neutral pt is deliberate: a future pt-PT is never served Brazilian strings by parent fallback. The consequence is that a request asking for a bare pt, or for an unsupported variant such as pt-PT, resolves to the configured default culture rather than to Portuguese. Both React apps canonicalize variants onto a supported tag before calling the API (see below), so only a hand-rolled client sending a bare pt sees this. Localization is registered with an empty resources path so the resx files resolve next to their marker types:
services.AddLocalization(o => o.ResourcesPath = "");Exceptions keep an English Message (what logs and audit records see) and carry the resource key alongside it; GlobalExceptionHandler resolves the key under the request culture when it renders the response, and falls back to Message when the key is missing. .Localized(...) attaches the key to any CustomException and returns the same instance, so the throw keeps its subclass and status code:
// module catalog: the marker type names itthrow new NotFoundException($"Product {id} not found.") .Localized<CatalogResources>("Catalog.ProductNotFound", id);
// shared catalog (SharedResources): no type argumentthrow new UnauthorizedException("Tenant context is required.") .Localized("Error.TenantContextRequired");MessageKey, MessageArgs and ResourceSource have private setters, so .Localized is the only way to set them. The wrappers that localize a BCL exception (LocalizedKeyNotFoundException, LocalizedUnauthorizedAccessException) do not derive from CustomException and keep an object initializer.
A key the catalog lacks fails the build. LocalizedKeyReferenceTests (in src/Tests/Architecture.Tests) scans src/BuildingBlocks and src/Modules for every string-literal key passed to .Localized<T>(...), .Localized(...), a wrapper’s MessageKey and localizer["..."], and fails when the key is missing from the neutral catalog it targets or when the entry’s {n} placeholders need more arguments than the call passes. Without it, a typo or a key added to the wrong module’s catalog would silently render the English fallback (or the raw key, for a validator).
Validators inject IStringLocalizer<SharedResources> (shared Validation.* keys) or IStringLocalizer<{Module}Resources> and resolve lazily with .WithMessage(_ => localizer["Key"]), so the message is looked up at validation time, under the request culture.
Placeholders are {0}, {1} and are filled by string.Format under CultureInfo.CurrentCulture, which is invariant (see below). Message arguments must therefore be culture-insensitive: pass int, long, string, Guid or an enum, never a double, decimal or DateTime, which would render with invariant separators whatever the reader’s language. An enum argument is itself localized: the handler looks up "{EnumType}.{Member}" in the same catalog and falls back to the member name.
Request localization
RequestLocalization is wired through the kit’s AddHeroLocalization / UseHeroLocalization pair, which the Web building block already calls from AddHeroPlatform / UseHeroPlatform, so the host’s Program.cs needs nothing extra. The middleware is placed after UseAuthentication so the culture chain can read the authenticated user’s locale claim, and before UseAuthorization and the endpoints:
// BuildingBlocks/Web/Extensions.cs (inside AddHeroPlatform / UseHeroPlatform)builder.Services.AddHeroLocalization(builder.Configuration);
// ... later, in the pipelineapp.UseAuthentication();app.UseHeroLocalization(); // must run after authentication// ...app.UseAuthorization();Only the UI culture is negotiated. The request culture drives CultureInfo.CurrentUICulture, which is what resource lookup uses. CultureInfo.CurrentCulture, which drives ToString(), Parse(), string interpolation and number or date formatting, stays pinned to CultureInfo.InvariantCulture on every request: DefaultRequestCulture carries (InvariantCulture, default culture) and SupportedCultures is left null, so the culture half can never take any other value. An API that emits JSON does not shift its formatting per caller; both React apps format numbers, dates and currency themselves. For a module author this means code under a pt-BR request still formats 1234.5 as 1234.5, and it is why message arguments must be culture-insensitive (above). Do not “fix” it by adding supported cultures: Formatting_culture_stays_invariant_while_ui_culture_negotiates fails if you do.
Culture resolution chain
For each request the UI culture is resolved by trying three providers in order, then the default. The first provider that yields a supported culture (one of SupportedCultures.Tags) wins; a value that is present but not in the list is discarded and resolution continues to the next provider. The chain always terminates with the default, so a culture is always set:
- Query string - a
?culture=value. Intended for quick testing and one-off overrides. There is no culture cookie: the framework’s cookie provider is removed from the chain. - JWT
localeclaim - the authenticated user’s persistedUser.Locale(see below), read byUserLocaleRequestCultureProvider, which is inserted right after the query-string provider. This is what makes a user’s preference follow them across devices. Accept-Languageheader - the browser’s / client’s advertised preference, used for anonymous requests and users who have never set a preference.- Default culture - the request localization’s
DefaultRequestCulture: the value ofLocalizationOptions:DefaultCulturewhen it is one ofSupportedCultures.Tags, otherwiseen-US(SupportedCultures.Default). The configured value is checked once at startup, so a missing or unsupported setting meansen-US, and a deployment configured forpt-BRfalls back topt-BR, not toen-US.
The resolved culture is echoed on the response’s Content-Language header.
Per-user language
User.Locale is a nullable BCP 47 language tag (for example pt-BR) on the identity user. It holds the user’s chosen language and is null until the user picks one. It is set through PUT /identity/profile, whose validator accepts only a tag in SupportedCultures.Tags (anything else is rejected with a 400 validation error, message key Validation.UnsupportedLocale); a blank locale on that call means “leave it unchanged”, so there is currently no way to clear a stored preference back to “follow the browser”.
The value is emitted as the JWT locale claim only when it is set - a user who has never chosen a language carries no locale claim, so their requests fall through to Accept-Language and then the default. Once set (through the language switcher, see below), every subsequently issued token carries the claim and the backend honours it on step 2 of the chain.
Localized messages
Three server-produced message surfaces are localized through the active culture:
- Problem details -
GlobalExceptionHandlerlocalizes thetitle(by status) and thedetail(from the exception’sMessageKeyandResourceSource, see above) it renders into the RFC 7807ProblemDetailsbody, so an error returned to the client is in the caller’s language. Becausedetailis culture-dependent, the handler also emits the exception’sMessageKeyas a stablecodeextension - a client that branches on a specific error keys off the code, never off the prose. See Error handling. - Validation - FluentValidation’s built-in messages are localized automatically because the framework picks them by
CultureInfo.CurrentUICulture, whichRequestLocalizationhas already set for the request. Custom validation messages are localized explicitly by resolving them throughIStringLocalizer. See Vertical Slice architecture for where validators live. - The 401 challenge - the
ProblemDetailsbody JwtBearer writes for a missing or rejected token never passes throughGlobalExceptionHandler, so it resolvesIStringLocalizer<SharedResources>itself and reads in the same language as every other error.
Frontend (admin and dashboard)
Both React apps localize the same way. Each uses react-i18next together with i18next-browser-languagedetector.
Catalogs
Translations are plain JSON catalogs, split by namespace, under:
src/locales/<locale>/<namespace>.jsonfor example src/locales/en-US/common.json and src/locales/pt-BR/common.json. Splitting by namespace keeps each catalog focused and lets a feature ship its own strings without touching one giant file.
Only en-US has to be complete. A translation may lag behind: a key missing from pt-BR (or a whole namespace without a pt-BR file) renders the English string, because the fallback chain always ends in en-US, even on a deployment whose default language is pt-BR (fallbackChain in src/lib/i18n-fallback.ts). Each app’s tests/i18n/parity.spec.ts fails only on a pt-BR key that en-US does not have (an orphan, usually a renamed or deleted key) and on a pt-BR value whose {{placeholders}} differ from the English one, so a contributor adding English strings never has to write Portuguese to get a green build. The backend catalogs follow the same rule.
Only en-US is bundled with the app. Every other language is a single chunk, built from src/locales/<locale>.ts (an import.meta.glob over that locale’s JSON files), that loads on demand: before the first render when the active language is not en-US, so a Portuguese user never sees English first, and before every switch, which goes through changeLanguage in src/i18n.ts. When the chunk cannot be loaded, the switcher keeps the current language and does not save the choice to the profile.
Keeping strings translated
Two build-time checks keep new code from slipping back into English.
Keys are typed. src/i18next.d.ts declares the en-US catalogs as i18next’s resources, so every t() key is checked against the namespace its translator is bound to, and a key that namespace lacks fails tsc -b (part of npm run build). Without it, a typo renders a label guessed from the key’s last segment in every language. Add the English entry first, then use it. A helper that receives the translator takes TFunction<"ns">, a key stored in data (navigation items, tabs, enum labels) is typed ParseKeys<"ns">, and an enum’s labels go through a map typed against the enum, so adding a member without a label stops compiling. A translator that reads another namespace lists it, as in useTranslation(["activity", "audits"]) with t("audits:...").
The lint blocks untranslated JSX text. eslint-plugin-i18next’s no-literal-string rule runs in jsx-text-only mode over src/pages and src/components, so <p>Save changes</p> fails npm run lint. Text inside <code> and <kbd>, runs made only of symbols and digits, the product name and the build tag are allowed by pattern in eslint.config.js. The rule does not see attribute values (aria-label, placeholder, title), strings outside JSX such as toast messages, or JSX outside those two folders; those remain a review item.
Language detection and normalization
The detector tries, in order, a ?culture= query-string value, the cached value in localStorage, and the browser’s language; when none yields a supported locale the app falls back to the deployment default (see below). It caches the resolved language in localStorage (no cookie is written), under a per-app key rather than the library default: fsh.admin.lng and fsh.dashboard.lng, matching every other value the apps persist (fsh.admin.accessToken, fsh.admin.theme, and so on). The default i18nextLng would be claimed by both apps on a shared origin and by any other i18next app deployed beside them. Detected values are passed through a convertDetectedLanguage step that normalizes variants to a supported locale through the CANON map in src/i18n.ts - for instance pt and pt-PT are mapped to pt-BR, en-GB to en-US - so the API never sees a bare pt, and a browser advertising a regional variant the app doesn’t ship still lands on a language it does.
Talking to the backend
Every request the app makes through apiFetch sends the active language as the Accept-Language header, so an anonymous or not-yet-personalized session still gets server messages in the language the UI is showing. The SignalR hub client is the exception: it builds its own requests rather than going through apiFetch, so its negotiate carries the browser’s Accept-Language.
Language switcher
A language switcher sits in the topbar. Changing the language does two things beyond re-rendering the UI:
- Persists the choice to
User.Localeby calling the profile update endpoint (PUT /identity/profile). The dashboard reads the profile with itsETagand sends it back asIf-Match, so a stale write is rejected with 412 instead of overwriting a concurrent change; the admin switcher does not sendIf-Matchyet. A failed save raises a toast. - Refreshes the access token, so the new preference is baked into the JWT
localeclaim and the backend honours it from the next request. The refresh is best-effort: if it fails, the session stays signed in and the claim catches up at the next natural refresh, so until then an API error can still come back in the previous language.
While an operator is impersonating a user in the dashboard, the switcher only changes the UI language: it sends no PUT, so the operator’s choice never lands on the impersonated user’s profile.
Locale-aware formatting
Dates, numbers, and currency are formatted in the browser with Intl under the active i18n.language, so they follow the active locale’s conventions rather than being hard-coded, keeping formatting consistent with the chosen language. The admin app centralizes this in src/lib/format.ts; the dashboard uses the helpers in src/lib/list-helpers.ts and passes i18n.language to Intl at its other call sites. This is the other half of the backend’s UI-culture-only negotiation: the API never formats for the caller, so the apps do.
Per-deployment default
The default language for a deployment is set at runtime through the app’s config.json, via the defaultLanguage key - consistent with how the front-ends take the rest of their environment configuration at runtime rather than from VITE_* build-time variables. In the container images of both apps, set FSH_DEFAULT_LANGUAGE (for example pt-BR); leaving it unset gives en-US. deploy/docker passes it through from .env, and Terraform sets it on both static sites from frontend_default_language.
Adding a language
Supporting a new language is three steps: one on the backend, one shared by both React apps, and an optional deployment default. Suppose you are adding Spanish (es-ES).
1. Backend - add the resource files and register the culture.
Create one translated resx per catalog, named for the specific culture next to the existing *.pt-BR.resx file: the shared catalog plus every module catalog.
src/BuildingBlocks/Core/Localization/SharedResources.es-ES.resxsrc/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.es-ES.resxsrc/Modules/Billing/Modules.Billing/Localization/BillingResources.es-ES.resx...one per module catalog listed under String resourcesThen add "es-ES" to SupportedCultures.Tags in src/BuildingBlocks/Core/Localization/SupportedCultures.cs. It is a list in code, not configuration, and it is the single whitelist that the locale-claim provider, Accept-Language matching and the User.Locale validator all read. Any tag not in this list is treated as unsupported and falls through the resolution chain, and PUT /identity/profile rejects it.
The parity tests hold every translated catalog consistent with the neutral one: a key the neutral catalog lacks (an orphan) or a {n} placeholder that differs fails the build, while a key or a whole .es-ES.resx that is still missing does not, and resolves to English at runtime. CatalogParityTests (in src/Tests/Architecture.Tests) discovers every catalog (shared and per module) and iterates SupportedCultures.Tags, so it covers the new culture with no change; SharedResourcesKeyParityTests (in src/Tests/Framework.Tests/Localization) and the per-module {Module}ResourcesTests compare against pt-BR by name, so extend them to the new tag.
2. Frontend - add catalogs and register the locale (repeat in both clients/admin and clients/dashboard).
Create one JSON catalog per namespace, mirroring every file under src/locales/en-US/:
src/locales/es-ES/common.jsonsrc/locales/es-ES/<other-namespace>.jsonThen add src/locales/es-ES.ts, a copy of src/locales/pt-BR.ts with the folder name changed, and register the locale in src/i18n.ts: add "es-ES": () => import("@/locales/es-ES") to LAZY_LANGUAGES, so the language ships as its own chunk, and add the tag to the SUPPORTED list, which is also what the switcher offers. Give the switcher a label by adding a language.esES key (the tag without its hyphen) to every locale’s common.json. To collapse a bare es or an unsupported variant such as es-MX onto the new tag, add es: "es-ES" to the CANON map; a tag listed in SUPPORTED is always kept as-is. Each app’s tests/i18n/parity.spec.ts checks pt-BR against en-US by name, so extend it to the new locale too; the fallback chain needs no change, since it always ends in en-US.
3. (Optional) Make it the deployment default.
If the new language should be the default rather than an opt-in, set it on the backend via LocalizationOptions:DefaultCulture and on each front-end via the defaultLanguage key in config.json.
Upgrading an existing project
Three changes can break code you wrote on top of an earlier version of the kit:
- ProblemDetails
titleis localized. For aCustomException,titleused to be the exception type name (for exampleCustomException). It is now a localized title for the status code, such asForbiddenorProibido. A client that branched ontitleshould branch on thecodeextension instead, which carries the stableMessageKey. PagedQueryValidator<T>needs a localizer. Its constructor now takesIStringLocalizer<SharedResources>, with no parameterless overload. Add it to your validator’s constructor and pass it to the base class; validators are resolved from DI, so nothing else changes.MessageKey,MessageArgsandResourceSourcehave private setters. Code that set them through an object initializer on aCustomExceptionno longer compiles. Use the.Localized<T>(key, args)factory instead (see String resources).
Configuration
Backend default culture (the default-culture step of the resolution chain). The section is optional and the shipped appsettings.json doesn’t include it; when it is absent, or names a culture that isn’t in SupportedCultures.Tags, the default is en-US:
{ "LocalizationOptions": { "DefaultCulture": "en-US" }}Front-end default language (runtime config.json, served to the app at startup):
{ "defaultLanguage": "en-US"}Gotchas
- A missing
localeclaim is normal. Users who have never chosen a language carry nolocaleclaim, so their culture comes fromAccept-Languageor the default - not a bug. - Catalogs are named for the specific tag.
SharedResources.pt-BR.resx, notSharedResources.pt.resx, matching the tag inSupportedCultures.Tagsand the front-end’ssrc/locales/pt-BR/folder;CatalogParityTestsreads each culture’s own catalog without parent fallback, so it sees exactly the keys that file declares. Whatever the file name, a bareptorpt-PTrequest resolves to the default culture, not to Portuguese, because the request culture is only ever one of the listed tags. - The
localeclaim lags a language switch by one token. The provider reads the claim, not the database, so a switch reaches the API when the refreshed token is in use; the switcher refreshes right away, but in between the UI can already be in the new language while an API error still arrives in the old one. UseHeroLocalizationmust run afterUseAuthentication. Placed earlier, the JWTlocaleclaim isn’t available yet, so step 2 of the chain is silently skipped and users fall back toAccept-Language.- Errors raised before localization runs are not in the caller’s language.
UseExceptionHandlersits aboveUseHeroLocalization; the handler re-applies the negotiated UI culture, so exceptions from endpoints are localized, but one thrown by middleware that runs before localization (HTTPS redirection, CORS, routing and so on) has no negotiated culture and is rendered in the server process’s own UI culture (English in a standard container), not the caller’s and notLocalizationOptions:DefaultCulture. - Formatting is invariant on the server.
CultureInfo.CurrentCultureisInvariantCultureon every request, whatever the caller’s language; onlyCurrentUICultureis negotiated. - Both sides must ship a language for it to work. Registering a locale in the front-end without the matching backend resx (or vice versa) leaves one surface un-localized.
- Containers run in globalization-invariant mode. The chiseled .NET runtime image the API ships on has no ICU and sets
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT, where creating a culture such aspt-BRthrowsCultureNotFoundExceptionand stops the API at startup.FSH.Starter.Api.csprojsets<PredefinedCulturesOnly>false</PredefinedCulturesOnly>so named cultures can be created there; resource lookup only needs the culture name, and formatting is invariant anyway. Keep that property if you build your own host project or image.
Related
- Core building block - where
SharedResourcesandSupportedCultureslive. - Web building block - where the localization wiring (
AddHeroLocalization,UserLocaleRequestCultureProvider) andGlobalExceptionHandlerlive. - Error handling - problem-details messages are localized through the active culture.
- Authentication - the JWT and its claims, including
locale. - Frontend: dashboard and Frontend: admin - the two React apps and their
apiFetchclient.