Click here to Skip to main content
15,887,214 members
Articles / Web Development / ASP.NET

ASP.NET 8 – Multilingual Application with Single Resx File

Rate me:
Please Sign up or sign in to vote.
5.00/5 (6 votes)
6 Mar 2024CPOL5 min read 8.8K   100   12   6
A practical guide to build a multi-language ASP.NET 8 MVC application
This is a practical guide to building a multi-language ASP.NET 8 MVC application where all language resource strings are kept in a single shared file, as opposed to having separate resource files for each controller/view.

1. The Need for a Newer Tutorial

There are a number of tutorials on how to build a multi-language application ASP.NET Core 8 MVC, but many are outdated for older versions of .NET or are vague on how to resolve the problem of having all language resources strings in a single file. So, the plan is to provide practical instructions on how that can be done, accompanied by code samples and a proof-of-concept example application.

1.1 Articles in this series

Articles in this series are:

2. Multilingual Sites, Globalization and Localization

I am not going to explain here what are the benefits of having a site in multiple languages, and what are Localization and Globalization. You can read it in many places on the internet (see [4]). I am going to focus on how to practically build such a site in ASP.NET Core 8 MVC. If you are not sure what .resx files are, this may not be an article for you.

3. Shared Resources Approach

By default, ASP.NET Core 8 MVC technology envisions separate resource file .resx for each controller and the view. But most people do not like it, since most multilanguage strings are the same in different places in the application, we would like it to be all in the same place. Literature [1] calls that approach the “Shared Resources” approach. In order to implement it, we will create a marker class SharedResoureces.cs to group all the resources. Then in our application, we will invoke Dependency Injection (DI) for that particular class/type instead of a specific controller/view. That is a little trick mentioned in Microsoft documentation [1] that has been a source of confusion in StackOverflow articles [6]. We plan to demystify it here. While everything is explained in [1], what is needed are some practical examples, like the one we provide here.

4. Steps to Multilingual Application

4.1 Configuring Localization Services and Middleware

Localization services are configured in Program.cs:

C#
private static void AddingMultiLanguageSupportServices(WebApplicationBuilder? builder)
{
    if (builder == null) { throw new Exception("builder==null"); };

    builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
    builder.Services.AddMvc()
            .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix);
    builder.Services.Configure<RequestLocalizationOptions>(options =>
    {
        var supportedCultures = new[] { "en", "fr", "de", "it" };
        options.SetDefaultCulture(supportedCultures[0])
            .AddSupportedCultures(supportedCultures)
            .AddSupportedUICultures(supportedCultures);
    });
}

private static void AddingMultiLanguageSupport(WebApplication? app)
{
    app?.UseRequestLocalization();
}

4.2 Create Marker Class SharedResources.cs

This is just a dummy marker class to group shared resources. We need it for its name and type.

It seems the namespace needs to be the same as the app root namespace, which needs to be the same as the assembly name. I had some problems when changing the namespace, it would not work. If it doesn't work for you, you can try to use the full class name in your DI instruction, like this one:

IStringLocalizer<SharedResources01.SharedResource> StringLocalizer

There is no magic in the name "SharedResource", you can name it "MyResources" and change all references in the code to "MyResources" and all will still work.

The location seems can be any folder, although some articles ([6] claim it needs to be the root project folder I do not see such problems in this example. To me, looks like it can be any folder, just keep your namespace tidy.

C#
//SharedResource.cs===================================================
namespace SharedResources01
{
    /*
    * This is just a dummy marker class to group shared resources
    * We need it for its name and type
    * 
    * It seems the namespace needs to be the same as app root namespace
    * which needs to be the same as the assembly name.
    * I had some problems when changing the namespace, it would not work.
    * If it doesn't work for you, you can try to use full class name
    * in your DI instruction, like this one:
    * IStringLocalizer<SharedResources01.SharedResource> StringLocalizer
    * 
    * There is no magic in the name "SharedResource", you can
    * name it "MyResources" and change all references in the code
    * to "MyResources" and all will still work
    * 
    * Location seems can be any folder, although some
    * articles claim it needs to be the root project folder
    * I do not see such problems in this example. 
    * To me looks it can be any folder, just keep your
    * namespace tidy. 
    */

    public class SharedResource
    {
    }
}

4.3 Create Language Resources Files

In the folder “Resources”, create your language resources files, and make sure you name them SharedResources.xx.resx.

Image 1

Image 2

4.4 Selecting Language/Culture

Based on [5], the Localization service has three default providers:

  1. QueryStringRequestCultureProvider
  2. CookieRequestCultureProvider
  3. AcceptLanguageHeaderRequestCultureProvider

Since most apps will often provide a mechanism to set the culture with the ASP.NET Core culture cookie, we will focus only on that approach in our example.

This is the code to set .AspNetCore.Culture cookie:

C#
private void ChangeLanguage_SetCookie(HttpContext myContext, string? culture)
{
    if(culture == null) { throw new Exception("culture == null"); };

    //this code sets .AspNetCore.Culture cookie
    myContext.Response.Cookies.Append(
        CookieRequestCultureProvider.DefaultCookieName,
        CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
        new CookieOptions { Expires = DateTimeOffset.UtcNow.AddMonths(1) }
    );
}

Cookie can be easily seen with Chrome DevTools:

Image 3

I built a small application to demo it, and here is the screen where I change the language:

Image 4

Note that I added some debugging info into the footer, to show the value of the Request language cookie, to see if the app is working as desired.

4.5 Using Localization Services in the Controller

In the controller is, of course, the Dependency Injection (DI) coming in and filling all the dependencies. The key thing is we are asking for a specific type=SharedResource.

If it doesn't work for you, you can try to use the full class name in your DI instruction, like this one:
IStringLocalizer<SharedResources01.SharedResource> stringLocalizer.

Here is the code snippet:

C#
public class HomeController : Controller
{
    private readonly ILogger<HomeController> _logger;
    private readonly IStringLocalizer<SharedResource> _stringLocalizer;
    private readonly IHtmlLocalizer<SharedResource> _htmlLocalizer;

    /* Here is, of course, the Dependency Injection (DI) coming in and filling 
     * all the dependencies. The key thing is we are asking for a specific 
     * type=SharedResource. 
     * If it doesn't work for you, you can try to use full class name
     * in your DI instruction, like this one:
     * IStringLocalizer<SharedResources01.SharedResource> stringLocalizer
     */
    public HomeController(ILogger<HomeController> logger, 
        IStringLocalizer<SharedResource> stringLocalizer,
        IHtmlLocalizer<SharedResource> htmlLocalizer)
    {
        _logger = logger;
        _stringLocalizer = stringLocalizer;
        _htmlLocalizer = htmlLocalizer;
    }
    
    //================================
    
    public IActionResult LocalizationExample(LocalizationExampleViewModel model)
{
    //so, here we use IStringLocalizer
    model.IStringLocalizerInController = _stringLocalizer["Wellcome"];
    //so, here we use IHtmlLocalizer
    model.IHtmlLocalizerInController = _htmlLocalizer["Wellcome"];
    return View(model);
}

4.6 Using Localization Services in the View

In the view is, of course, the Dependency Injection (DI) coming in and filling all the dependencies. The key thing is we are asking for a specific type=SharedResource.

If it doesn't work for you, you can try to use the full class name in your DI instruction, like this one:

C#
IStringLocalizer<SharedResources01.SharedResource> stringLocalizer

Here is the code snippet:

Razor
@* LocalizationExample.cshtml ====================================================*@
@using Microsoft.AspNetCore.Mvc.Localization
@using Microsoft.Extensions.Localization

@model LocalizationExampleViewModel

@* Here is of course the Dependency Injection (DI) coming in and filling
all the dependencies. The key thing is we are asking for a specific
type=SharedResource. 
If it doesn't work for you, you can try to use full class name
in your DI instruction, like this one:
@inject IStringLocalizer<SharedResources01.SharedResource> StringLocalizer
 *@

@inject IStringLocalizer<SharedResource> StringLocalizer
@inject IHtmlLocalizer<SharedResource> HtmlLocalizer

@{
    <div style="width:600px">
        <p class="bg-info">
            IStringLocalizer Localized  in Controller: 
            @Model.IStringLocalizerInController
        </p>

        <p class="bg-info">
            @{
                string? text1 = StringLocalizer["Wellcome"];
            }
            IStringLocalizer Localized  in View: @text1
        </p>

        <p class="bg-info">
            IHtmlLocalizer Localized  in Controller: 
            @Model.IHtmlLocalizerInController
        </p>

        <p class="bg-info">
            @{
                string? text2 = "Wellcome";
            }
            IHtmlLocalizer Localized  in View: @HtmlLocalizer[@text2]
        </p>
    </div>
}

4.7 Execution Result

Here is what the execution result looks like:

Image 5

Note that I added some debugging info into the footer, to show the value of the Request language cookie, to see if the app is working as desired.

4.8 Problem with IHtmlLocalizer<SharedResource>

I had some problems with IHtmlLocalizer<SharedResource>. It resolves strings and translates them, which shows the setup is correct. But, it didn’t work for HTML, as advertised. I tried to translate even simple HTML like “<b>Wellcome</b>”, but it would not work. But it works for simple strings like “Wellcome”.

5. Full Code

Since most people like code they can copy-paste, here is the full code of the application.

C#
//Program.cs===========================================================================
namespace SharedResources01
{
    public class Program
    {
        public static void Main(string[] args)
        {
            //=====Middleware and Services=============================================
            var builder = WebApplication.CreateBuilder(args);

            //adding multi-language support
            AddingMultiLanguageSupportServices(builder);

            // Add services to the container.
            builder.Services.AddControllersWithViews();

            //====App===================================================================
            var app = builder.Build();

            //adding multi-language support
            AddingMultiLanguageSupport(app);

            // Configure the HTTP request pipeline.
            if (!app.Environment.IsDevelopment())
            {
                app.UseExceptionHandler("/Home/Error");
            }
            app.UseStaticFiles();

            app.UseRouting();

            app.UseAuthorization();

            app.MapControllerRoute(
                name: "default",
                pattern: "{controller=Home}/{action=ChangeLanguage}/{id?}");

            app.Run();
        }

        private static void AddingMultiLanguageSupportServices
                                       (WebApplicationBuilder? builder)
        {
            if (builder == null) { throw new Exception("builder==null"); };

            builder.Services.AddLocalization
                    (options => options.ResourcesPath = "Resources");
            builder.Services.AddMvc()
                    .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix);
            builder.Services.Configure<RequestLocalizationOptions>(options =>
            {
                var supportedCultures = new[] { "en", "fr", "de", "it" };
                options.SetDefaultCulture(supportedCultures[0])
                    .AddSupportedCultures(supportedCultures)
                    .AddSupportedUICultures(supportedCultures);
            });
        }

        private static void AddingMultiLanguageSupport(WebApplication? app)
        {
            app?.UseRequestLocalization();
        }
    }
}

//SharedResource.cs===================================================
namespace SharedResources01
{
    /*
    * This is just a dummy marker class to group shared resources
    * We need it for its name and type
    * 
    * It seems the namespace needs to be the same as app root namespace
    * which needs to be the same as the assembly name.
    * I had some problems when changing the namespace, it would not work.
    * If it doesn't work for you, you can try to use full class name
    * in your DI instruction, like this one:
    * IStringLocalizer<SharedResources01.SharedResource> StringLocalizer
    * 
    * There is no magic in the name "SharedResource", you can
    * name it "MyResources" and change all references in the code
    * to "MyResources" and all will still work
    * 
    * Location seems can be any folder, although some
    * articles claim it needs to be the root project folder
    * I do not see such problems in this example. 
    * To me looks it can be any folder, just keep your
    * namespace tidy. 
    */

    public class SharedResource
    {
    }
}

//HomeController.cs================================================================
namespace SharedResources01.Controllers
{
    public class HomeController : Controller
    {
        private readonly ILogger<HomeController> _logger;
        private readonly IStringLocalizer<SharedResource> _stringLocalizer;
        private readonly IHtmlLocalizer<SharedResource> _htmlLocalizer;

        /* Here is, of course, the Dependency Injection (DI) coming in and filling 
         * all the dependencies. The key thing is we are asking for a specific 
         * type=SharedResource. 
         * If it doesn't work for you, you can try to use full class name
         * in your DI instruction, like this one:
         * IStringLocalizer<SharedResources01.SharedResource> stringLocalizer
         */
        public HomeController(ILogger<HomeController> logger, 
            IStringLocalizer<SharedResource> stringLocalizer,
            IHtmlLocalizer<SharedResource> htmlLocalizer)
        {
            _logger = logger;
            _stringLocalizer = stringLocalizer;
            _htmlLocalizer = htmlLocalizer;
        }

        public IActionResult ChangeLanguage(ChangeLanguageViewModel model)
        {
            if (model.IsSubmit)
            {
                HttpContext myContext = this.HttpContext;
                ChangeLanguage_SetCookie(myContext, model.SelectedLanguage);
                //doing funny redirect to get new Request Cookie
                //for presentation
                return LocalRedirect("/Home/ChangeLanguage");
            }

            //prepare presentation
            ChangeLanguage_PreparePresentation(model);
            return View(model);
        }

        private void ChangeLanguage_PreparePresentation(ChangeLanguageViewModel model)
        {
            model.ListOfLanguages = new List<SelectListItem>
                        {
                            new SelectListItem
                            {
                                Text = "English",
                                Value = "en"
                            },

                            new SelectListItem
                            {
                                Text = "German",
                                Value = "de",
                            },

                            new SelectListItem
                            {
                                Text = "French",
                                Value = "fr"
                            },

                            new SelectListItem
                            {
                                Text = "Italian",
                                Value = "it"
                            }
                        };
        }

        private void ChangeLanguage_SetCookie(HttpContext myContext, string? culture)
        {
            if(culture == null) { throw new Exception("culture == null"); };

            //this code sets .AspNetCore.Culture cookie
            myContext.Response.Cookies.Append(
                CookieRequestCultureProvider.DefaultCookieName,
                CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
                new CookieOptions { Expires = DateTimeOffset.UtcNow.AddMonths(1) }
            );
        }

        public IActionResult LocalizationExample(LocalizationExampleViewModel model)
        {
            //so, here we use IStringLocalizer
            model.IStringLocalizerInController = _stringLocalizer["Wellcome"];
            //so, here we use IHtmlLocalizer
            model.IHtmlLocalizerInController = _htmlLocalizer["Wellcome"];
            return View(model);
        }

        public IActionResult Error()
        {
            return View(new ErrorViewModel 
            { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
        }
    }
}

//ChangeLanguageViewModel.cs=====================================================
namespace SharedResources01.Models.Home
{
    public class ChangeLanguageViewModel
    {
        //model
        public string? SelectedLanguage { get; set; } = "en";

        public bool IsSubmit { get; set; } = false;

        //view model
        public List<SelectListItem>? ListOfLanguages { get; set; }
    }
}

//LocalizationExampleViewModel.cs===============================================
namespace SharedResources01.Models.Home
{
    public class LocalizationExampleViewModel
    {
        public string? IStringLocalizerInController { get; set; }
        public LocalizedHtmlString? IHtmlLocalizerInController { get; set; }
    }
}
Razor
@* ChangeLanguage.cshtml ===================================================*@
@model ChangeLanguageViewModel

@{
    <div style="width:500px">
        <p class="bg-info">
            <partial name="_Debug.AspNetCore.CultureCookie" /><br />
        </p>

        <form id="form1">
            <fieldset class="border rounded-3 p-3">
                <legend class="float-none w-auto px-3">Change Language</legend>
                <div class="form-group">
                    <label asp-for="SelectedLanguage">Select Language</label>
                    <select class="form-select" asp-for="SelectedLanguage"
                            asp-items="@Model.ListOfLanguages">
                    </select>
                    <input type="hidden" name="IsSubmit" value="true">
                    <button type="submit" form="form1" 
                     class="btn btn-primary mt-3 float-end"
                            asp-area="" asp-controller="Home" 
                            asp-action="ChangeLanguage">
                        Submit
                    </button>
                </div>
            </fieldset>
        </form>
    </div>
}

@* LocalizationExample.cshtml ====================================================*@
@using Microsoft.AspNetCore.Mvc.Localization
@using Microsoft.Extensions.Localization

@model LocalizationExampleViewModel

@* Here is of course the Dependency Injection (DI) coming in and filling
all the dependencies. The key thing is we are asking for a specific
type=SharedResource. 
If it doesn't work for you, you can try to use full class name
in your DI instruction, like this one:
@inject IStringLocalizer<SharedResources01.SharedResource> StringLocalizer
 *@

@inject IStringLocalizer<SharedResource> StringLocalizer
@inject IHtmlLocalizer<SharedResource> HtmlLocalizer

@{
    <div style="width:600px">
        <p class="bg-info">
            IStringLocalizer Localized  in Controller: 
            @Model.IStringLocalizerInController
        </p>

        <p class="bg-info">
            @{
                string? text1 = StringLocalizer["Wellcome"];
            }
            IStringLocalizer Localized  in View: @text1
        </p>

        <p class="bg-info">
            IHtmlLocalizer Localized  in Controller: 
            @Model.IHtmlLocalizerInController
        </p>

        <p class="bg-info">
            @{
                string? text2 = "Wellcome";
            }
            IHtmlLocalizer Localized  in View: @HtmlLocalizer[@text2]
        </p>
    </div>
}

6. References

7. History

  • 6th March, 2024: Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer
Serbia Serbia
Mark Pelf is the pen name of just another Software Engineer from Belgrade, Serbia.
My Blog https://markpelf.com/

Comments and Discussions

 
SuggestionTypealizR Pin
earloc20-Mar-24 1:54
earloc20-Mar-24 1:54 
QuestionQuestion about filling the resx file Pin
gabriel52six8-Mar-24 10:13
gabriel52six8-Mar-24 10:13 
QuestionVery Nice Pin
Be Unique from Bombay7-Mar-24 23:59
Be Unique from Bombay7-Mar-24 23:59 
PraiseRe: Very Nice Pin
Mark Pelf 8-Mar-24 1:04
mvaMark Pelf 8-Mar-24 1:04 
GeneralMy vote of 5 Pin
Ștefan-Mihai MOGA6-Mar-24 14:54
professionalȘtefan-Mihai MOGA6-Mar-24 14:54 
PraiseRe: My vote of 5 Pin
Mark Pelf 8-Mar-24 2:09
mvaMark Pelf 8-Mar-24 2:09 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.