skip to content
Khalid Abuhakmeh logo Khalid Abuhakmeh

C# Localization in ASP.NET Core with IStringLocalizer

A guide to C# localization in ASP.NET Core with IStringLocalizer and IViewLocalizer: RequestLocalizationMiddleware, resx files, and culture switching.

7 min read Updated
A desk globe on a table, representing internationalization and localization. Photo by Kyle Glenn on Unsplash.

Quick answer

C# localization in ASP.NET Core centers on IStringLocalizer<T>, which resolves translated strings from .resx resource files using a type's namespace. Register it with AddLocalization, add RequestLocalizationMiddleware to the pipeline, then inject IStringLocalizer<T> (or IHtmlLocalizer<T> in Razor views) to return localized values.

Language is a core component of the human condition. According to the Washington Post, at least 50% of the world’s population is bilingual. That’s a fantastic statistic; that means every second user of our application could be bilingual. Sadly, most app implementations don’t support multiple languages and may miss serving a large audience.

In this post, we’ll cover the quick steps necessary to add C# localization to an ASP.NET Core application and target multiple cultures: registering the localization services, wiring up RequestLocalizationMiddleware, organizing .resx resource files, and resolving translated text through IStringLocalizer<T>.

Set Up RequestLocalizationMiddleware

Like most new cross-cutting functionality in ASP.NET Core, localization is implemented as middleware. Like most middleware implementations, enabling this feature takes two essential steps: registering the services, then adding it to our ASP.NET Core pipeline. In .NET 10’s minimal hosting model, both happen in Program.cs.

Let’s first look at what we need to register the configuration for request localization.

var builder = WebApplication.CreateBuilder(args);
// 1. Register Razor Pages + view localization
builder.Services
.AddRazorPages()
.AddViewLocalization();
// 2. Tell the localizers where the .resx files live
builder.Services.AddLocalization(options =>
{
options.ResourcesPath = "Resources";
});
// 3. Configure the default and supported cultures
builder.Services.Configure<RequestLocalizationOptions>(options =>
{
var cultures = new[] { "en-US", "de-DE", "ja-JP" };
options.SetDefaultCulture("en-US");
options.AddSupportedCultures(cultures);
options.AddSupportedUICultures(cultures);
options.FallBackToParentUICultures = true;
});

The first step is to add the services required by the middleware. In our case, we’ll be localizing Razor Pages. To do that, we need to make a call to AddViewLocalization. This call registers a few services we’ll be using later in our view. Here is the internal implementation of the method.

services.TryAdd(ServiceDescriptor.Singleton<IHtmlLocalizerFactory, HtmlLocalizerFactory>());
services.TryAdd(ServiceDescriptor.Transient(typeof(IHtmlLocalizer<>), typeof(HtmlLocalizer<>)));
services.TryAdd(ServiceDescriptor.Transient<IViewLocalizer, ViewLocalizer>());

The second step tells the localization middleware where to start looking for resx files. In our case, we’ll be creating a folder named Resources under our project structure.

The localization services work by using the namespace of a class to determine the resource file location. We’ll get more into this later when we start localizing values in our views. For now, here is the layout of the project.

WebApplication/
├─ Program.cs
├─ Pages/
│ ├─ Components/
│ ├─ Shared/
│ ├─ _ViewImports.cshtml
│ ├─ _ViewStart.cshtml
│ ├─ Error.cshtml
│ ├─ Index.cshtml // view for WebApplication.Pages.IndexModel
│ └─ Privacy.cshtml
└─ Resources/
├─ Program.resx // IStringLocalizer<Program> — global strings
├─ Program.de.resx
├─ Program.ja.resx
└─ Pages/
├─ IndexModel.resx // IStringLocalizer<IndexModel>
├─ IndexModel.de.resx
└─ IndexModel.ja.resx

The .de and .ja files hold the German and Japanese translations. Because we set FallBackToParentUICultures = true, a request for de-DE or ja-JP resolves to these neutral-culture files when a region-specific resource isn’t present.

The third and final step is to configure our options and supported cultures. In our case, we’ll support German, English, and Japanese. An important note on the difference between CurrentCulture and CurrentUICulture: CurrentCulture localizes system types like dates and currency, while the UI uses CurrentUICulture, which in our case drives the Razor view engine.

Next, we build the app and register our middleware in the pipeline before mapping endpoints. The ordering depends on the application’s other middleware, so developers will need to adjust it for their apps.

If we need to access the request’s culture in our own middleware, we’ll need to register UseRequestLocalization before that custom middleware.

For clarity, I’ve included the rest of Program.cs below.

var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.MapStaticAssets();
// add request localization middleware
app.UseRequestLocalization();
app.UseRouting();
app.UseAuthorization();
app.MapRazorPages()
.WithStaticAssets();
app.Run();

What Is IStringLocalizer<T>?

The IStringLocalizer<T> interface is the heart of C# localization in ASP.NET Core. It uses the namespace of type T to determine the name of a resource file.

If our application has a type WebApplication.Pages.IndexModel, then a request for IStringLocalizer<WebApplication.Pages.IndexModel> will look for a resource instance under the folder prefix we set up during service registration concatenated with our namespace. In this case, it would look for a resource with the namespace WebApplication.Resources.Pages.IndexModel.

In other words, the type’s namespace maps directly onto the file path:

IStringLocalizer<WebApplication.Pages.IndexModel>
│
ResourcesPath ─┐ │ namespace after the assembly root
▼ ▼
Resources / Pages / IndexModel.resx

The naming approach is merely conventional, and the type of T could be anything. In our sample, we also have a resource for IStringLocalizer<Program>, which will hold all of our global string values.

If we were to use IViewLocalizer, the naming and placement of our resource files would become more critical.

The default implementation of IViewLocalizer finds the resource file based on the view’s file name. –Microsoft

In my opinion, the IViewLocalizer implementation is a friendly approach to enforcing conventions but is also limiting as it excludes access to global shared resource files. Using IStringLocalizer and IHtmlLocalizer provides more flexibility.

Using The Localizers

The localizers are services and, like other services, can be accessed using ASP.NET Core’s dependency injection container.

@page
@using Microsoft.Extensions.Localization
@using Microsoft.AspNetCore.Mvc.Localization
@model IndexModel
@inject IStringLocalizer<IndexModel> Localizer
@inject IHtmlLocalizer<IndexModel> HtmlLocalizer
@{
ViewData["Title"] = "Home page";
}
<div class="text-center">
<h1 class="display-4">@Localizer["Welcome"]</h1>
<p>
@HtmlLocalizer["Learn"]
</p>
</div>

In this example, we are utilizing two interfaces, IStringLocalizer and IHtmlLocalizer, with each accessing the resource files associated with IndexModel.resx.

A recommendation from reading the documentation is that keys also be the fallback value. Take the following example.

<div>This is awesome!</div>

The Microsoft documentation recommends the following usage of IStringLocalizer.

<div>@Localizer["This is awesome!"]</div>

The localizer implementation will return the key as a LocalizedString type. As developers, we can still build in our native tongue while sowing the seeds for localization.

Switching Cultures

Localization doesn’t work unless our application understands how to switch cultures. The RequestLocalizationMiddleware has three default mechanisms for changing between cultures:

  • QueryStringRequestCultureProvider - looks for a culture= query string value.
  • CookieRequestCultureProvider - looks for a .AspNetCore.Culture cookie with a culture value.
  • AcceptHeadersRequestCultureProvider - looks for an Accept-Language header in incoming HTTP requests.

These mechanisms are configurable using the RequestLocalizationOptions we registered during service registration. We won’t be going into detail on how to configure the providers, but knowing it’s possible is worth mentioning.

In the next section, you’ll see how we can utilize the QueryStringRequestCultureProvider to switch between cultures.

The Localization Demo

Putting all these pieces together, we have a functioning, localized web application. A .resx file is XML. Here’s Resources/Pages/IndexModel.de.resx holding the German translations:

<data name="Welcome" xml:space="preserve">
<value>Willkommen</value>
</data>
<data name="Learn" xml:space="preserve">
<value>Erfahren Sie mehr über das Erstellen von ASP.NET Core-Apps.</value>
</data>

With the culture picker wired up, changing the culture= value swaps every localized string on the page:

?culture= @Localizer["Welcome"] renders
en-US 🇺🇸 Welcome
de-DE 🇩🇪 Willkommen
ja-JP 🇯🇵 ようこそ

For folks who want to play around with this code sample, it is available on a GitHub repository.

Our culture picker is a simple HTML form that submits whenever the value changes.

@model WebApplication.ViewComponents.CulturePickerModel
<div>
<form method="GET">
<div class="form-group">
<label class="sr-only" for="culture-picker">
Culture
</label>
<select
id="culture-picker"
class="form-control"
name="culture"
onchange="this.parentElement.parentElement.submit();">
@foreach (var culture in Model.SupportedCultures)
{
<option
value="@culture.Name"
selected="@(Model.CurrentUICulture.Name == culture.Name)">
<span>@Model.ToFlagEmoji(culture.Name)</span> @culture.DisplayName - @culture.NativeName
</option>
}
</select>
</div>
</form>
</div>

We also use a neat Unicode trick to display the culture’s country flag using the later part of the culture name. For example, the culture of ja-JP would yield a country code of JP.

public string ToFlagEmoji(string country)
{
country = country
.Split('-')
.LastOrDefault();
if (country == null)
return "⁉️️";
return string.Concat(
country
.ToUpper()
.Select(x => char.ConvertFromUtf32(x + 0x1F1A5))
);
}

Conclusion

Localization may seem scary, but the ASP.NET team has taken care to make it manageable. Tools like JetBrains Rider with its localization manager make editing values quick and efficient. The internet has made it possible to localize our apps with the help of language experts to correct local idioms. When it comes to localization in ASP.NET, once we understand how the pieces fit together, it’s just a matter of rinse and repeat.

I want to point you to some great resources that helped me learn more about localization.

Comments

Related Posts