Blazor (Wasm) – How to add support for multiple languages (Globalization)

One of the first questions we ask when a client asks for a new application is, “Is supporting multiple languages a requirement?” It is always so much easier to bake this sort of functionality into an application upfront, rather than try to refactor it in at the 11th hour. In this post, we will outline one approach to implementing the basics of allowing users to choose their preferred language in a client-side Blazor WebAssembly (Wasm) application.

To make this process as easy as possible, we will be using classic .resx resource files. These files can include resources such as strings, images, or object data that can change based upon the chosen culture / language. Before we get started, it is important to note that this is not the only way to implement Globalization, particularly for applications with access to server-side resources. For a more database-oriented approach combined with a web API, check out BlazorBoilerplate’s Localizer implementation. With that said, let’s dive right in from a point that assumes you already have a Blazor Wasm project created.

The first thing you will want to do is add a few NuGet package references to the client project:

  • Microsoft.Extensions.Localization
  • Blazored.LocalStorage
  • MatBlazor (optional)

Likewise, in your _Imports.Razor file, you should include corresponding @using statements for those packages:

@using Microsoft.Extensions.Localization;
@using Blazored.LocalStorage

Adding and using these NuGet packages will make the process easier as we build out the remaining components required to support multiple languages.

Resource files in the project

Our next step is to simply add a “neutral” or default .resx file (Resources.resx in the image) as well as an additional resource file for each supported language. The file names of additional .resx files must incorporate either a two-letter language code or more specific culture info name. For a quick reference on these codes, click here.

Sample resource file with public access modifier

Add one or more key/value pair rows (such as “WelcomeTitle” in the image) to each file for use in testing. Without going into too much detail that is well-documented elsewhere, this is a good time to mention two things:

  • The Access Modifier for each .resx file must be set to Public, as highlighted in the above image.
  • You must open up your client .csproj project file and add/set the BlazorWebAssemblyLoadAllGlobalizationData element to true.

Because our application supports dynamically changing the culture, we must configure a BlazorWebAssemblyLoadAllGlobalizationData element in the .csproj project file as such:

<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
    <ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
    <BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData>
  </PropertyGroup>
...

If this element is not configured, you will receive a runtime error:

System.InvalidOperationException due to not setting BlazorWebAssemblyLoadAllGlobalizationData in project file

blazor.webassembly.js:1 System.InvalidOperationException: Blazor detected a change in the application’s culture that is not supported with the current project configuration.

With that detail handled, let’s move on to creating a new LocalizerSettings.cs class file to encapsulate and enumerate the cultures our application supports. In the next section, we will use this class to populate a dropdown list for the user to choose a language.

    public static class LocalizerSettings
    {
        public const string NeutralCulture = "en-US";

        public static readonly string[] SupportedCultures = { NeutralCulture, "de-DE", "fr-CA", "pt-PT", "es-MX" };

        public static readonly (string, string)[] SupportedCulturesWithName = new[] { ("English", NeutralCulture), ("Deutsch", "de-DE"), ("French", "fr-CA"), ("Português", "pt-PT"), ("Spanish", "es-MX") };
    }

To make use of the LocalizerSettings static class, create a new shared component named SelectCulture.razor that we can later reference in our MainLayout.razor page. The SelectCulture.razor code shown below injects the necessary services and the creates a dropdown list using items from the LocalizerSettings class created previously. This code was written to use the MatSelectValue component from MatBlazor. If you are not using that component library, simply substitute whatever code is appropriate for your scenario to create an HTML select list.

@using System.Globalization
@inject ILocalStorageService _localStorage
@inject NavigationManager _navigationManager

<MatSelectValue Class="ml-md-auto" Value="CurrentCulture" 
        Items="@LocalizerSettings.SupportedCulturesWithName"
        ValueSelector=@(i => i.Item2)
        ValueChanged="(string i) => OnCultureChanged(i)">
    <ItemTemplate Context="CultureItem">
        <span>@CultureItem.Item1</span>
    </ItemTemplate>
</MatSelectValue>

@code {
    private string CurrentCulture { get; set; }

    protected override async Task OnInitializedAsync()
    {
        CurrentCulture = CultureInfo.CurrentCulture.Name;

        await base.OnInitializedAsync();
    }

    public void OnCultureChanged(string culture)
    {
        _localStorage.SetItemAsync<string>("culture", culture);
        _navigationManager.NavigateTo(_navigationManager.Uri, forceLoad: true);
    }
}

Adding items to the browser’s local storage is as easy as calling SetItemAsync<T>() and, likewise, retrieving them later using the GetItemAsync<T>() method from our ILocalStorageService component. Okay, now that SelectCulture.razor in place, let’s open up MainLayout.razor and add a reference to the SelectCulture component.

    <div class="main">
        <div class="top-row px-4">
            <SelectCulture></SelectCulture>
            &nbsp;
            <a href="https://www.msctek.com/" target="_blank" class="ml-md-auto">About</a>
        </div>

        <div class="content px-4">
            @Body
        </div>
    </div>

Next, change the Index.razor page to inject and use the IStringLocalizer for testing purposes. The name of the resource key should match whatever you used when creating the .resx files and, if you added to the

@page "/"
@inject IStringLocalizer<Resource> _localizer

@_localizer[nameof(Resource.WelcomeTitle)]

We are almost done, with the exception of adding one more class file and adjusting the application startup code in Program.cs. Let’s start by adding a new static class file named WebAssemblyHostExtensions.cs to the project.

    public static class WebAssemblyHostExtensions
    {
        public async static Task SetDefaultCulture(this WebAssemblyHost host)
        {
            var localStorage = host.Services.GetRequiredService<ILocalStorageService>();
            var cultureString = await localStorage.GetItemAsync<string>("culture");

            CultureInfo cultureInfo;

            if (!string.IsNullOrWhiteSpace(cultureString))
            {
                cultureInfo = new CultureInfo(cultureString);
            }
            else
            {
                cultureInfo = new CultureInfo("en-US");
            }

            CultureInfo.DefaultThreadCurrentCulture = cultureInfo;
            CultureInfo.DefaultThreadCurrentUICulture = cultureInfo;
        }
    }

The code in this file is pretty straightforward to understand, but let me point out a couple of nuances:

  • The signature of SetDefaultCulture() in line 3 uses the this keyword to create an extension method. This will be important when we use the WebAssemblyHostExtensions class in the next step.
  • Notice that the “culture” string used on line 5 matches the same key used to store the user’s selection in the OnCultureChanged() method from our SelectCulture component.

Before we run the application, the last area that needs to be configured in the Main() method of the Program.cs file. Here, we want to add our localization service from the Microsoft.Extensions.Localization package as well as the local storage service provided by the Blazored.LocalStorage NuGet package.

    public static async Task Main(string[] args)
    {
        var builder = WebAssemblyHostBuilder.CreateDefault(args);
        builder.RootComponents.Add<App>("#app");

        builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
        builder.Services.AddMatBlazor();
        builder.Services.AddLocalization();
        builder.Services.AddBlazoredLocalStorage();

        var host = builder.Build();
        await host.SetDefaultCulture(); // Retrieves local storage value and sets the thread's current culture.
        await host.RunAsync();
    }

Notice how the code around builder.Build() has been broken up into multiple lines so that we can invoke the SetDefaultCulture() extension method created previously in the WebAssemblyHostExtensions.cs file. When the user triggers a change in our SelectCulture.razor component, executing the _navigationManager.NavigateTo(_navigationManager.Uri, forceLoad: true); code forces the application to reload. This triggers the code in our Main() method, which, in turn, calls our SetDefaultCulture() extension method to set the chosen culture on the current thread.

Finally, you made it! Compile and run the application.

As a final point, if you prefer to use view models, as I do, then simply inject the IStringLocalizer service into your class as such to make the service available:

        [Inject]
        protected IStringLocalizer<Resource> Localizer { get; set; }

This image shows the files that should either be added or modified in your project for you to to use in case an item was missed. Note that you do not need the Base*ViewModel.cs class if you are not using ViewModels.

May the source be with you!

WARNING:

After celebrating just how “easy” this process was, the next day I moved on to make what seemed like completely innocuous and unrelated code changes and this functionality broke! If this happens to you, these are the steps I took to “fix” it.

  • Moved the “neutral” .resx file to the root of the Blazor client project
  • Renamed the “ResourceFiles” folder to “Resources” and left all culture-specific files in that folder
  • Added a new “Resources.en.resx” file (previously unnecessary, but the English translations in the neutral file stopped working and this resolved that issue)
  • Deleted any value from the Custom Tool Namespace property of the neutral .resx file (culture-specific are also blank)
  • Changed the localalization registration in Program.Main() to:
builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");

After making the above changes, the project folders for resource files now appear as shown in this image.

For more details on this issue, see: https://github.com/dotnet/aspnetcore/issues/32359

Leave a Comment

Your email address will not be published.