Software localization
Getting Started With ASP.NET MVC i18n
ASP.NET MVC is a modern Model-View-Controller framework for building web applications built on Microsoft's large and reliable .NET environment. You probably already knew that. In fact, you're probably building an ASP.NET MVC app and you're looking to globalize it so that you can serve it in different languages. Well, have no fear. In this article, we'll build a small demo app and globalize its UI and routes, giving you a foundation to build on as you develop and deliver your app to users from all over the world. ASP.NET MVC i18n, here we come!
Globalization, i18n, l10n ... Oh my!
Outside of .NET, we often refer to the process of getting an application ready for delivery to people in different parts of the world as internationalization, abbreviated as i18n. This usually means not hard-coding our UI strings so that their translations can be used dynamically. It also often means we're aware of regional differences when it comes to dates and calendars, currency, and more. It's worth noting that Microsoft calls this process globalization in their documentation. So we'll use the terms globalization and i18n interchangeably here.
Localization, or l10n, is the process of building on i18n and providing the actual translations and regional formatting that is required for a given locale. That's mostly fancy talk for l10n == translation (although there's a bit more to it).
Oh, and while we're at it: A locale is a combination of a language and a region, like "Canadian English", and is often denoted with a code like "en-CA". In .NET, this is called a culture. Again, we'll use the terms locale and culture interchangeably here.
Alright enough with the semantics. Let's get to building.
The demo app
Our little demo will be a simple web app called Heveanture, a foray into the world of constellations.
Our home page will list constellations
Each constellation will have a details page
Nothing too crazy, and it will allow us to cover basic i18n pretty well.
🔗 Resource » You can get all the code for the app we will build here from the app's GitHub repo.
Framework and package versions
We're using the following IDE, frameworks, and packages to build this demo app, with versions at the time of writing.
- Visual Studio 2019
- .NET 4.7
- ASP.NET MVC 5.2
We'll also be working in C#, although a lot of what we cover should apply to any language that works on top of .NET.
✋🏽 Heads Up » We're using the traditional .NET framework, which generally requires Windows, not to be confused with .NET Core, the newer cross-platform variant of .NET.
🔗 Resource » If you're interested in .NET Core, make sure you stop by our full-stack i18n with Angular and .NET Core tutorial.
Creating the project
We'll get started by opening Visual Studio and creating a new project. In the Create a new project dialog, let's select the ASP.NET Web Application (.NET Framework) template with the C# label (again not ASP.NET Core) and click Next.
Using the search box can help filter to the template we want
In the Configure your new project dialog, we can enter the name of our app, and click Create.
We're using .NET 4.7 here
And, in the Create a new ASP.NET Web Application dialog, we can select the MVC template and click Create.
Would you create the project already?!
Alright, that should be it for creating the project. If we now run the project using the green play button in Visual Studio, we should be greeted with a placeholder home page.
Featuring out-of-the-box Bootstrap CSS for styling
Building our app
Let's start cleaning up some of the scaffolding that Visual Studio has given us and get our own app logic and styles in place.
We'll start with a mock view model that represents our constellations. We won't be touching the database layer in this article. If you would like us to cover database globalization with ASP.NET MVC, please let us know in the comments below :). For now, some hard-coded data will get us globalizing the front end.
using System; using System.Collections.Generic; namespace Heaventure.Data { public class Constellation { public int Id { get; private set; } public string Name { get; private set; } public string Description { get; private set; } public string ImageUrl { get; private set; } public int StarCount { get; private set; } public DateTime CreatedAt { get; private set; } public static List<Constellation> All() { return new List<Constellation>() { new Constellation() { Id = 1, Name = "Capricornus", Description = "Capricornus /ˌkæprɪˈkɔːrnəs/ is one of the constellations of the zodiac. Its name is Latin for \"horned goat\" or \"goat horn\" or \"having horns like a goat's\", and it is commonly represented in the form of a sea goat: a mythical creature that is half goat, half fish. Its symbol is Capricorn.svg (Unicode ♑).", ImageUrl = "~/Content/Images/Capricornus.png", StarCount = 3, CreatedAt = new DateTime(2020, 04, 22) }, new Constellation() { Id = 2, Name = "Aries", Description = "Aries is one of the constellations of the zodiac. It is located in the Northern celestial hemisphere between Pisces to the west and Taurus to the east. The name Aries is Latin for ram, and its symbol is Aries.svg (Unicode ♈), representing a ram's horns.", ImageUrl = "~/Content/Images/Aries.png", StarCount = 18, CreatedAt = new DateTime(2020, 04, 20) }, new Constellation() { Id = 3, Name = "Hydrus", Description = "Hydrus /ˈhaɪdrəs/ is a small constellation in the deep southern sky. It was one of twelve constellations created by Petrus Plancius from the observations of Pieter Dirkszoon Keyser and Frederick de Houtman.", ImageUrl = "~/Content/Images/Hydrus.png", StarCount = 18, CreatedAt = new DateTime(2020, 03, 30) }, new Constellation() { Id = 4, Name = "Puppis", Description = "Puppis /ˈpʌpɪs/ is a constellation in the southern sky. Puppis, the Poop Deck, was originally part of an over-large constellation, the ship of Jason and the Argonauts, Argo Navis, which centuries after its initial description, was divided into three parts, the other two being Carina (the keel and hull), and Vela (the sails of the ship).", ImageUrl = "~/Content/Images/Puppis.png", StarCount = 9, CreatedAt = new DateTime(2020, 04, 14) }, new Constellation() { Id = 5, Name = "Telescopium", Description = "Telescopium is a minor constellation in the southern celestial hemisphere, one of twelve named in the 18th century by French astronomer Nicolas-Louis de Lacaille and one of several depicting scientific instruments. Its name is a Latinized form of the Greek word for telescope.", ImageUrl = "~/Content/Images/Telescopium.png", StarCount = 2, CreatedAt = new DateTime(2020, 04, 19) }, new Constellation() { Id = 6, Name = "Ursa Major", Description = "Ursa Major (/ˈɜːrsə ˈmeɪdʒər/; also known as the Great Bear) is a constellation in the northern sky, whose associated mythology likely dates back into prehistory. Its Latin name means \"greater (or larger) she-bear,\" referring to and contrasting it with nearby Ursa Minor, the lesser bear.", ImageUrl = "~/Content/Images/UrsaMajor.png", StarCount = 20, CreatedAt = new DateTime(2020, 03, 21) } }; } public static Constellation FindById(int id) => All().Find(constellation => constellation.Id == id); } }
Constellation
has a few simple properties, a hard-coded All()
method that retrieves a List<Constellation>
, and a FindById()
method that finds and returns a constellation by its Id
. Let's wire this model up to our Home
controller.
using Heaventure.Data; using System.Web.Mvc; namespace Heaventure.Controllers { public class HomeController : Controller { public ActionResult Index() { var model = Constellation.All(); return View(model); } public ActionResult Details(int id) { var model = Constellation.FindById(id); return View(model); } } }
We just query the data and pass it on to our views. Speaking of which, let's build those.
@model List<Heaventure.Data.Constellation> @{ ViewBag.Title = "Home Page"; } <div class="row mt-lg"> @foreach (var constellation in Model) { <div class="col-md-4"> <div class="panel panel-default"> <div class="panel-body panel-img-container"> <a href="@Url.Action("Details", new { Id = constellation.Id })"> <img src="@Url.Content(constellation.ImageUrl)" class="img-responsive" /> </a> </div> <div class="panel-footer"> <h3 class="panel-title text-center"> @Html.ActionLink( constellation.Name, "Details", new { Id = constellation.Id }) </h3> </div> </div> </div> } </div>
Our index view displays our constellations as columns, with images and names. The image and name of each constellation link to its respective details page.
@model Heaventure.Data.Constellation @{ ViewBag.Title = Model.Name; Layout = "~/Views/Shared/_Layout.cshtml"; } <div class="row mt-lg"> <div class="col-md-4"> <img src="@Url.Content(Model.ImageUrl)" class="img-responsive img-rounded" /> </div> <div class="col-md-8"> <h2 class="mt-0">@Model.Name</h2> <p>@Model.Description</p> <dl class="dl-horizontal"> <dt>Number of Stars</dt> <dd>@Model.StarCount</dd> <dt>Added</dt> <dd>@Model.CreatedAt</dd> </dl> </div> </div>
In our details view, we simply display the constellation's image and properties in an orderly fashion.
We also update our CSS, adding a Boostrap theme from Bootswatch called Cyborg, removing extraneous views and actions, and adding our constellation images.
🔗 Resource » If you would like to see all the changes we made up to this point, checkout the commit tagged "start" in the demo app's GitHub repo. You can also checkout the start commit if you want to code along with us, and you want to get to globalization right away, without building everything we have up to this point yourself.
When we run our app now, we see this beauty:
Humans have always connected the dots peppering the void of space
The Great Bear growls
Using resource files for localized messages
.NET supports resource files (.resx) for translation messages. It can be a bit tricky to set up resource files if you've never done it before, however. Let's walk through it step-by-step.
Creating resource files in Visual Studio
First, let's create a folder/namespace that houses our resource files. In the Visual Studio Solution Explorer, we can right-click on our project (Heaventure), and select Add > New Folder. We can name this folder anything we want. I'll go with Resources.
Next, let's create our default resources file. We can right-click on the folder we just created and select Add > New Item. There doesn't seem to be a template for resources files in Visual Studio 2019, but there's an easy workaround for this. We can select the Visual C# > General tab in the sidebar and select Text File. Then, we can name the file Resources.resx, and click Add.
Make sure to change the file extension to .resx
Once we've added the file, we can open it in Visual Studio.
A collection of name-value pairs
✋🏽 Heads Up » At this point, we need to make sure to click the Access Modifier dropdown and select Public. Otherwise, our resource file won't work.
We just created our app's default, English resource file. We can now repeat the above process for each additional culture our app supports. We need to follow the naming convention Resources.{culture-code}.resx, otherwise .NET won't load the correct file when we switch cultures later. I'll add an Arabic resource file named Resources.ar.resx, and make sure to set its Access Modifier to Public.
Adding translations to resource files
At this point, we can add a string to Resources.resx. Let's open the file and add our application's name in English.
Don't forget to save the file
If we want to add an Arabic translation for our app's name, we can open our Resources.ar.resx file and add a string with the same Name we used in our English Resources.resx. We then can add the Arabic translation as the Value for the string.
Same name, different language
Using resource strings in our views
Let's pull our newly adding string into our _Views > Shared > Layout.cshtml file.
<!DOCTYPE html> <html> <head> <!-- ... --> <title>@ViewBag.Title - @Heaventure.Resources.AppName</title> <!-- ... --> </head> <body> <div class="navbar navbar-inverse navbar-fixed-top"> <div class="container"> <div class="navbar-header"> <!-- ... --> @Html.ActionLink( Heaventure.Resources.AppName, "Index", "Home", new { area = "" }, new { @class = "navbar-brand" }) </div> <!-- ... --> </div> </div> <!-- ... --> </body> </html>
Instead of the hard-coded string, we're now using Heaventure.Resources.AppName
.
To see the benefit of what we've just done, we can go into our HomeController
's Index
action and set the culture to Arabic before we return our view.
using Heaventure.Data; using System.Web.Mvc; namespace Heaventure.Controllers { public class HomeController : Controller { public ActionResult Index() { var ar = new System.Globalization.CultureInfo("ar"); System.Threading.Thread.CurrentThread.CurrentCulture = ar; System.Threading.Thread.CurrentThread.CurrentUICulture = ar; var model = Constellation.All(); return View(model); } // ... } }
We'll go through setting culture in more detail in a little bit. We're just trying to see if our resource files are working for now. After adding the code above, we can run our app and visit the root route (/) to load the index view.
We now have translated messages!
We can now remove the hard-coded culture setting we added to HomeController
; we won't be needing it.
🗒 Note » .NET will automatically fall back onto the string with the same name in the default Resources.resx if it can't find it in Resources.{current-culture}.resx.
Adding the resources namespace to Web.config
Typing Heaventure.Resources.{Name}
everywhere we want a translated message seems a bit too verbose. We don't have to use the fully qualified namespace, however. We can make our lives easier by adding the Heaventure.Resources
namespace to our Web.config
.
<?xml version="1.0"?> <configuration> <!-- ... --> <system.web.webPages.razor> <host factoryType="System.Web.Mvc.MvcWebRazorHostFactory, System.Web.Mvc, Version=5.2.7.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35" /> <pages pageBaseType="System.Web.Mvc.WebViewPage"> <namespaces> <add namespace="System.Web.Mvc" /> <add namespace="System.Web.Mvc.Ajax" /> <add namespace="System.Web.Mvc.Html" /> <add namespace="System.Web.Optimization"/> <add namespace="System.Web.Routing" /> <add namespace="SmartFormat"/> <add namespace="Heaventure" /> <add namespace="Heaventure.Resources"/> </namespaces> </pages> </system.web.webPages.razor> <!-- ... --> </configuration>
Inside the <namespaces>
element, we can <add namespace="Heaventure.Resources"/>
. Once we do, we can type Resources.AppName
instead of Heaventure.Resources.AppName
in our views.
Alright, that's basic translation strings taken care of. Now let's see how we can set our app's current culture via routes.
.NET culture
Let's take a look at how .NET deals with locales, or cultures. .NET sets a culture per thread, so when we want to set or get the current culture, we need to do something like the following.
using System; using System.Threading; using System.Globalization; class MainClass { public static void Main (string[] args) { // Will print the current culture, which // depends on your system settings Console.WriteLine(Thread.CurrentThread.CurrentCulture); // Will print the current UI culture, which // depends on your system settings Console.WriteLine(Thread.CurrentThread.CurrentUICulture); var french = new CultureInfo("fr"); Thread.CurrentThread.CurrentCulture = french; Thread.CurrentThread.CurrentUICulture = french; // Will print "fr" Console.WriteLine(Thread.CurrentThread.CurrentCulture); // Will print "fr" Console.WriteLine(Thread.CurrentThread.CurrentUICulture); } }
CultureInfo
is the class that defines culture in .NET. It contains a wealth of information about a given culture, including its name, currency format, calendar, and much more.
🔗 Resource » Check out the official .NET documentation for more information about CultureInfo.
🤿 Go deeper »We've compiled a more comprehensive guide on all you need to know about CultureInfo in .NET applications that goes beyond the overview we give here.
The difference between Culture and UICulture
You may have wondered why we're setting both CurrentCulture
and CurrentUICulture
in the code above. Well, the two properties are responsible for different things.
CurrentUICulture
deals with resource files (.resx), like the ones we created above. If we set CurrentUICulture
to Arabic, for example, .NET will load the Resources.ar.resx
file automatically.
CurrentCulture
deals with almost everything else when it comes to localization: formatting and parsing of values and sorting, among other things.
Setting our app's culture
Let's get back to coding, and use the information we know about .NET cultures to set the culture in our app depending on a route parameter. This means that hitting a route like /en/Details/1
will load our app in the default, English culture. Hitting a route like /ar/Details/1
will load the same view in Arabic.
Localized routes
We can configure routes like the ones we outlined above by updating our App_Start > RouteConfig.cs file.
using System.Web.Mvc; using System.Web.Routing; namespace Heaventure { public class RouteConfig { public static void RegisterRoutes(RouteCollection routes) { routes.IgnoreRoute("{resource}.axd/{*pathInfo}"); routes.MapRoute( name: "Root", url: "", defaults: new { controller = "Base", action = "RedirectToLocalized" } ); routes.MapRoute( name: "Default", url: "{culture}/{controller}/{action}/{id}", defaults: new { culture = "en", controller = "Home", action = "Index", id = UrlParameter.Optional }, constraints: new { culture = "en|ar" } ); } } }
Note that we're redirecting our root route (/) to a localized route with our default culture (English in this case). To accomplish this, we're setting a default culture
value in our localized route. We're also routing our root to BaseController.RedirectoToLocalized()
action.
A base controller
Adding a base controller for all our other controllers gives us a place to put common behavior that may look awkward in other controllers, like the RedirectToLocalized()
action. BaseController
, of course, has to derive from .NET's MVC Controller
.
using System.Globalization; using System.Threading; using System.Web.Mvc; namespace Heaventure.Controllers { public class BaseController : Controller { public ActionResult RedirectToLocalized() { return RedirectPermanent("/en"); } } }
The BaseController
is also a good place to set the app's culture based on the current culture
route parameter. To do this, we can override Controller
's OnActionExecuting()
, which is run before every action on the controller.
using System.Globalization; using System.Threading; using System.Web.Mvc; namespace Heaventure.Controllers { public class BaseController : Controller { protected override void OnActionExecuting( ActionExecutingContext filterContext) { // Grab the culture route parameter string culture = filterContext.RouteData.Values["culture"]?.ToString() ?? "en"; // Set the action parameter just in case we didn't get one // from the route. filterContext.ActionParameters["culture"] = culture; var cultureInfo = CultureInfo.GetCultureInfo(culture); Thread.CurrentThread.CurrentCulture = cultureInfo; Thread.CurrentThread.CurrentUICulture = cultureInfo; // Because we've overwritten the ActionParameters, we // make sure we provide the override to the // base implementation. base.OnActionExecuting(filterContext); } public ActionResult RedirectToLocalized() { return RedirectPermanent("/en"); } } }
We yank the culture value out of the RouteData.Values
dictionary, and use it to set our CurrentCulture
and CurrentUICulture
in our app.
Now we can update our HomeController
(and any other controller in our app) to derive from BaseController
.
using Heaventure.Data; using System.Web.Mvc; namespace Heaventure.Controllers { public class HomeController : BaseController { // ... } }
With that in place, when we attempt to hit the root route (/), we're redirected to /en
. If we go to /ar
, we can see our app name appearing in Arabic.
Our localized routes are now setting our app's culture
🔗 Resource » We go into setting an app's culture in more detail in our dedicated article How Do I Set Culture in an ASP.NET MVC App?
A simple language/culture switcher
Let's provide our app's users a simple dropdown to allow them to switch cultures using our new localized route system.
<ul class="nav navbar-nav navbar-right"> <li class="dropdown"> <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false" > @System.Threading.Thread.CurrentThread.CurrentCulture.EnglishName <span class="caret"></span> </a> <ul class="dropdown-menu"> <li><a href="/en">English</a></li> <li><a href="/ar">Arabic</a></li> </ul> </li> </ul>
We're using a Bootstrap .dropdown
here, and we're wrapping it in a .navbar-right
so we can embed it in our main _Layout.cshtml
.
<!DOCTYPE html> <html> <!-- ... --> <body> <div class="navbar navbar-inverse navbar-fixed-top"> <div class="container"> <div class="navbar-header"> <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse" > <span class="icon-bar"></span> <span class="icon-bar"></span> <span class="icon-bar"></span> </button> @Html.ActionLink( Resources.AppName, "Index", "Home", new { area = "" }, new { @class = "navbar-brand" }) </div> <div class="navbar-collapse collapse"> <ul class="nav navbar-nav"> <li>@Html.ActionLink("Home", "Index", "Home")</li> </ul> @Html.Partial("_CultureSwitcher") </div> </div> </div> <!-- ... --> </body> </html>
Now, when we run the app, we have a working language switcher.
Clicking a language takes you to its localized route
🔗 Resource » Grab all the code for the demo app we built here from the app's GitHub repo.
And with that in place, we have working globalization in our app!
Conclusion
We hope you've enjoyed this little adventure into ASP.NET MVC i18n. Globalization can be a lot of work, but it doesn't have to be a pain in the neck. Imagine that you can run a CLI command, and your resource files are automatically sent to translators. When the translators are done working with the resource files in a beautiful web UI, they can save them, and you can sync them back to your project. This and more is possible with Phrase Strings. Built by developers for developers, Phrase Strings is a battle-tested localization platform with a developer CLI and API. Featuring GitHub, GitLab, and Bitbucket sync, Phrase Strings takes care of the i18n plumbing to allow you to focus on the creative code you love. Check out all of Phrase's products, and sign up for a free 14-day trial.