The Complete Guide to ASP.NET Core Localization

From IStringLocalizer to production: set up resource-based localization in ASP.NET Core, then automate translations with AI.

1

Enable Localization Services

Register localization services in Program.cs with AddLocalization(), configure supported cultures, and add the request localization middleware. This wires up the entire localization pipeline for your ASP.NET Core application.

AddLocalization() registers IStringLocalizer and IStringLocalizerFactory in the DI container. ResourcesPath tells the framework where to find your .resx files. AddViewLocalization() enables IViewLocalizer in Razor views, and AddDataAnnotationsLocalization() enables localized validation messages.
Program.cs
using Microsoft.AspNetCore.Localization;
using System.Globalization;

var builder = WebApplication.CreateBuilder(args);

// 1. Register localization services
builder.Services.AddLocalization(o => o.ResourcesPath = "Resources");

// 2. Add MVC with view/data-annotation localization
builder.Services.AddControllersWithViews()
    .AddViewLocalization()
    .AddDataAnnotationsLocalization();

var app = builder.Build();

// 3. Configure supported cultures
var supportedCultures = new[] { "en", "de", "ja", "es", "pt-BR" }
    .Select(c => new CultureInfo(c)).ToArray();

app.UseRequestLocalization(new RequestLocalizationOptions
{
    DefaultRequestCulture = new RequestCulture("en"),
    SupportedCultures = supportedCultures,
    SupportedUICultures = supportedCultures,
});

app.UseStaticFiles();
app.UseRouting();
app.MapControllers();
app.Run();
2

Create RESX Resource Files

ASP.NET Core uses RESX (XML resource) files for translations. Create one file per culture per class: HomeController.en.resx, HomeController.de.resx, etc. The framework resolves the correct file based on the current request culture.

Resources/Controllers/HomeController.{culture}.resx
<!-- Resources/Controllers/HomeController.en.resx -->
<?xml version="1.0" encoding="utf-8"?>
<root>
  <data name="Welcome" xml:space="preserve">
    <value>Welcome to our application</value>
  </data>
  <data name="Greeting" xml:space="preserve">
    <value>Hello, {0}!</value>
  </data>
</root>

<!-- Resources/Controllers/HomeController.de.resx -->
<?xml version="1.0" encoding="utf-8"?>
<root>
  <data name="Welcome" xml:space="preserve">
    <value>Willkommen in unserer Anwendung</value>
  </data>
  <data name="Greeting" xml:space="preserve">
    <value>Hallo, {0}!</value>
  </data>
</root>
Use a SharedResource class with its own RESX files for strings shared across controllers and views — button labels, navigation items, and common validation messages. This avoids duplicating keys across dozens of controller-specific RESX files.
Shared resources for cross-cutting strings
<!-- Resources/SharedResource.en.resx — shared across controllers -->
<?xml version="1.0" encoding="utf-8"?>
<root>
  <data name="AppName" xml:space="preserve">
    <value>My Application</value>
  </data>
  <data name="Save" xml:space="preserve"><value>Save</value></data>
  <data name="Cancel" xml:space="preserve"><value>Cancel</value></data>
</root>

// Marker class (empty — only used for type lookup)
namespace MyApp;
public class SharedResource { }
3

Use IStringLocalizer in Controllers and Services

Inject IStringLocalizer<T> into any controller, service, or middleware via dependency injection. The generic type parameter T determines which RESX file to load. Use bracket syntax localizer["Key"] to retrieve translated strings, with optional format parameters.

Controllers/HomeController.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;

public class HomeController(
    IStringLocalizer<HomeController> localizer,
    IStringLocalizer<SharedResource> shared) : Controller
{
    public IActionResult Index()
    {
        ViewData["Welcome"] = localizer["Welcome"];
        ViewData["AppName"] = shared["AppName"];

        // String interpolation with format parameters
        var greeting = localizer["Greeting", User.Identity?.Name ?? "Guest"];
        return View(new HomeViewModel { Greeting = greeting });
    }
}
If IStringLocalizer returns the key name instead of the translated value, check three things: (1) the RESX file naming matches the class namespace, (2) ResourcesPath in AddLocalization() points to the correct folder, and (3) the RESX file's Build Action is set to Embedded Resource in Visual Studio.
4

Configure Request Culture Middleware

ASP.NET Core determines the request culture using a chain of providers: query string, cookie, and Accept-Language header (in that order). You can add custom providers — for example, reading the culture from a URL route segment like /de/home.

Custom RouteDataRequestCultureProvider
// Culture resolved in order: QueryString, Cookie, Accept-Language
// Custom provider: read culture from URL route segment /de/home
public class RouteDataRequestCultureProvider : RequestCultureProvider
{
    public override Task<ProviderCultureResult?> DetermineProviderCultureResult(
        HttpContext httpContext)
    {
        var culture = httpContext.GetRouteValue("culture")?.ToString();
        if (string.IsNullOrEmpty(culture))
            return NullProviderCultureResult;
        return Task.FromResult<ProviderCultureResult?>(
            new ProviderCultureResult(culture));
    }
}

// Register in Program.cs (route provider first = highest priority):
app.UseRequestLocalization(new RequestLocalizationOptions
{
    DefaultRequestCulture = new RequestCulture("en"),
    SupportedCultures = supportedCultures,
    SupportedUICultures = supportedCultures,
    RequestCultureProviders = new List<IRequestCultureProvider>
    {
        new RouteDataRequestCultureProvider(),
        new QueryStringRequestCultureProvider(),
        new CookieRequestCultureProvider(),
        new AcceptLanguageHeaderRequestCultureProvider(),
    }
});
Language Switcher Action
// Language switcher: persist choice in cookie
[HttpPost]
public IActionResult SetLanguage(string culture, string returnUrl)
{
    Response.Cookies.Append(
        CookieRequestCultureProvider.DefaultCookieName,
        CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
        new CookieOptions { Expires = DateTimeOffset.UtcNow.AddYears(1) });
    return LocalRedirect(returnUrl);
}
The order of middleware matters. UseRequestLocalization() must be called after UseRouting() but before UseEndpoints() or MapControllers(). If placed too late, the culture will not be set when your controllers execute.
5

Localize Data Annotations

Validation attributes like [Required], [StringLength], and [Display] can be localized by setting their ErrorMessage or Name properties to RESX key names. Call AddDataAnnotationsLocalization() in Program.cs to enable this.

ViewModels/RegisterViewModel.cs
using System.ComponentModel.DataAnnotations;

public class RegisterViewModel
{
    [Required(ErrorMessage = "NameRequired")]
    [Display(Name = "FullName")]
    [StringLength(100, ErrorMessage = "NameLength", MinimumLength = 2)]
    public string Name { get; set; } = string.Empty;

    [Required(ErrorMessage = "EmailRequired")]
    [EmailAddress(ErrorMessage = "EmailInvalid")]
    [Display(Name = "EmailAddress")]
    public string Email { get; set; } = string.Empty;

    [Required(ErrorMessage = "PasswordRequired")]
    [StringLength(100, ErrorMessage = "PasswordLength", MinimumLength = 8)]
    [Display(Name = "Password")]
    public string Password { get; set; } = string.Empty;
}
// RESX keys map to ErrorMessage/Name values:
// RegisterViewModel.de.resx: NameRequired = "Name ist erforderlich"
// RegisterViewModel.de.resx: FullName = "Vollständiger Name"
Data annotation localization uses the ViewModel class name to find RESX files, not the Controller. For RegisterViewModel, the framework looks for Resources/ViewModels/RegisterViewModel.de.resx. If your RESX files are named after the controller, validation messages will not be localized.
6

Handle Plurals and ICU Messages

.NET does not have built-in plural rule support like ICU. For simple cases, use separate RESX keys (ItemCount_One, ItemCount_Other) with a code switch. For full ICU MessageFormat support across all CLDR plural categories, use the MessageFormat.NET library.

Plural handling strategies
// Option 1: Separate RESX keys with code switch
// HomeController.en.resx: ItemCount_One = "You have {0} item"
// HomeController.en.resx: ItemCount_Other = "You have {0} items"
public string GetItemCount(int count)
{
    var key = count == 1 ? "ItemCount_One" : "ItemCount_Other";
    return _localizer[key, count];
}

// Option 2: ICU MessageFormat (dotnet add package MessageFormat.NET)
using Jeffijoe.MessageFormat;
var formatter = new MessageFormatter();

var pattern = "{count, plural, one {# item} other {# items}} in your cart";
var result = formatter.FormatMessage(pattern,
    new Dictionary<string, object> { { "count", 5 } });
// => "5 items in your cart"

// Arabic: 6 plural forms (zero, one, two, few, many, other)
var arPattern = @"{count, plural,
    zero {لا عناصر} one {عنصر واحد} two {عنصران}
    few {# عناصر} many {# عنصرًا} other {# عنصر}}";
Never use count == 1 to detect singular forms. French treats 0 as singular. Russian has separate forms for 'few' and 'many'. Arabic has six plural categories. Use CLDR-aware plural rules or a library like MessageFormat.NET that handles this correctly.
7

Localize Razor Views

Use IViewLocalizer in Razor views via @inject. It resolves RESX files based on the view's file path. For HTML-safe strings with markup, use IHtmlLocalizer. Tag Helpers like asp-for and asp-validation-for automatically use localized Display and ErrorMessage attributes.

Views/Home/Index.cshtml
@using Microsoft.AspNetCore.Mvc.Localization
@inject IViewLocalizer Localizer
@inject IHtmlLocalizer<SharedResource> SharedHtml

<h1>@Localizer["Welcome"]</h1>
<p>@Localizer["Greeting", User.Identity?.Name]</p>

@* IHtmlLocalizer: does NOT escape — use for RESX values with HTML *@
<p>@SharedHtml["TermsNotice"]</p>

@* Tag Helpers auto-localize Display/ErrorMessage attributes *@
<form asp-action="Register">
    <label asp-for="Name"></label>
    <input asp-for="Name" />
    <span asp-validation-for="Name"></span>
    <button type="submit">@Localizer["Submit"]</button>
</form>
IViewLocalizer resolves RESX files by view path: Views/Home/Index.cshtml looks for Resources/Views/Home/Index.de.resx. If you want to share strings across views, inject IStringLocalizer<SharedResource> separately.
8

Automate RESX Translations

With your localization setup complete, translate your RESX files using AI. In your IDE, ask your AI assistant to translate your source RESX, or use the i18n Agent CLI in your CI/CD pipeline to keep translations in sync.

Terminal
# In your IDE, ask your AI assistant:
> Translate Resources/Controllers/HomeController.en.resx to German, Japanese, Spanish

# HomeController.de.resx created (1.2s)
# HomeController.ja.resx created (1.5s)
# HomeController.es.resx created (1.1s)

# Or use the CLI in CI/CD:
npx i18n-agent translate Resources/Controllers/HomeController.en.resx --lang de,ja,es
Translate incrementally — when you add new keys to your source RESX file, translate just the diff rather than regenerating all files. This preserves any human-reviewed translations and minimizes churn.

Automate Translation Quality

Catch missing keys and broken placeholders before they ship with i18n-validate. Test your UI with pseudo-translations using i18n-pseudo before real translations arrive.

Fix Locale Fallback with LocaleChain.NET

.NET's built-in CultureInfo.Parent hierarchy uses BCP 47 truncation only: pt-BR falls back to pt then InvariantCulture, skipping pt-PT. LocaleChain.NET provides configurable per-locale fallback chains for the entire .NET ecosystem.

Without LocaleChain.NET, a pt-BR user sees English when a Portuguese string is missing — even if you have a complete pt-PT translation. The same problem affects es-MX (skips es-419), zh-Hant (skips zh-Hans), and dozens of other regional variants.
Terminal
dotnet add package I18nAgent.LocaleChain
Program.cs
using I18nAgent.LocaleChain;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddLocalization(o => o.ResourcesPath = "Resources");

// Zero-config: built-in chains (pt-BR -> pt-PT -> pt -> en, etc.)
LocaleChain.Configure();

// Or customize specific chains:
LocaleChain.Configure(new Dictionary<string, string[]>
{
    ["pt-BR"] = new[] { "pt-PT", "pt", "en" },
    ["es-MX"] = new[] { "es-419", "es", "en" },
});

// Register the chain-aware string localizer
builder.Services.AddSingleton(
    typeof(IStringLocalizer<>),
    typeof(LocaleChainStringLocalizer<>));

Common Pitfalls

Culture Not Set on Request

Translations always show the default language. Check that UseRequestLocalization() is called in the middleware pipeline and that the culture providers are configured. Verify the browser sends Accept-Language headers. Test with ?culture=de in the query string to confirm the middleware works.

RESX File Not Found

IStringLocalizer returns the key name instead of the translated value. The most common cause is a RESX file name that does not match the class's full namespace path relative to ResourcesPath. Enable debug logging for Microsoft.Extensions.Localization to see which paths the framework searches.

Middleware Order Incorrect

UseRequestLocalization() must appear before UseEndpoints() and MapControllers(). If placed after, the request culture is not set when controllers execute. In .NET 6+ minimal hosting, call it before app.MapControllers().

Background Thread Uses Wrong Culture

CultureInfo.CurrentCulture and CurrentUICulture are per-thread. Background tasks (Task.Run, hosted services) inherit the thread pool culture, not the request culture. Explicitly capture and set the culture when dispatching background work.

Recommended Project Structure

Project Structure
MyAspNetApp/
├── Controllers/
│   └── HomeController.cs
├── ViewModels/
│   └── RegisterViewModel.cs
├── Views/
│   └── Home/
│       └── Index.cshtml
├── Resources/
│   ├── Controllers/
│   │   ├── HomeController.en.resx     # English (source)
│   │   ├── HomeController.de.resx     # German
│   │   └── HomeController.ja.resx     # Japanese
│   ├── ViewModels/
│   │   ├── RegisterViewModel.en.resx
│   │   └── RegisterViewModel.de.resx
│   ├── Views/Home/
│   │   ├── Index.en.resx
│   │   └── Index.de.resx
│   └── SharedResource.en.resx
├── SharedResource.cs                  # Marker class
├── Program.cs
└── MyAspNetApp.csproj

Try i18n Agent Now

Drop your translation file here

JSON, YAML, PO, XML, CSV, Markdown, Properties

or click to browse

Target languages

No signup requiredInstant estimate

Frequently Asked Questions