{"id":8528,"date":"2026-10-26T06:15:00","date_gmt":"2026-10-26T06:15:00","guid":{"rendered":"https:\/\/poeditor.com\/blog\/?p=8528"},"modified":"2026-10-26T08:54:32","modified_gmt":"2026-10-26T08:54:32","slug":"asp-net-core-localization","status":"publish","type":"post","link":"https:\/\/poeditor.com\/blog\/asp-net-core-localization\/","title":{"rendered":"ASP.NET Core localization: How to translate a Razor Pages app with .resx files"},"content":{"rendered":"\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"489\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/ASP.NET-Core-localization-1024x489.png\" alt=\"\" class=\"wp-image-8540\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/ASP.NET-Core-localization-1024x489.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/ASP.NET-Core-localization-300x143.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/ASP.NET-Core-localization-766x366.png 766w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/ASP.NET-Core-localization-1536x734.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/ASP.NET-Core-localization.png 1920w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">ASP.NET Core is Microsoft\u2019s open-source framework for building web apps and APIs in C#. It runs on Windows, Linux and macOS and includes built-in support for localization.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If your app is available in several languages, you\u2019ll need to account for more than translated text. Dates, numbers and other values should follow the conventions of each language, too. Internationalization (i18n) prepares your code to handle different languages, while localization (l10n) provides the translations and language-specific formatting.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In this tutorial, we\u2019ll add French to an ASP.NET Core Razor Pages TO-DO app. We\u2019ll use IStringLocalizer and .resx resource files to translate the interface, including plural forms and validation messages, and adjust date and number formatting for each language.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Overview<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"#Registering-the-localization-services\">Registering the localization services<\/a><\/li>\n\n\n\n<li><a href=\"#Creating-a-shared-.resx-resource-file\">Creating a shared .resx resource file<\/a><\/li>\n\n\n\n<li><a href=\"#Using-IStringLocalizer-in-Razor-Pages\">Using IStringLocalizer in Razor Pages<\/a><\/li>\n\n\n\n<li><a href=\"#Translating-validation-messages-and-display-names\">Translating validation messages and display names<\/a><\/li>\n\n\n\n<li><a href=\"#Handling-plurals-with-ICU-MessageFormat\">Handling plurals with ICU MessageFormat<\/a><\/li>\n\n\n\n<li><a href=\"#Adding-a-language-switcher\">Adding a language switcher<\/a><\/li>\n\n\n\n<li><a href=\"#Formatting-dates-and-numbers-with-CultureInfo\">Formatting dates and numbers with CultureInfo<\/a><\/li>\n\n\n\n<li><a href=\"#Translating-the-.resx-file-in-POEditor\">Translating the .resx file in POEditor<\/a><\/li>\n<\/ul>\n\n\n\n<h2 id=\"Registering-the-localization-services\" class=\"wp-block-heading\">Registering the localization services<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The TO-DO app is built with ASP.NET Core 10 Razor Pages and uses Entity Framework Core to store its data in SQLite. You register with a username and password, create named lists, add tasks to them, mark tasks as done and filter each list by open or completed tasks. All the text is hardcoded in English in the <code>.cshtml<\/code> pages, page models and form models. Plurals are handled with a <code>count == 1<\/code> check, and a small Format class uses American date formats.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"639\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/01_lists_page_en-1024x639.png\" alt=\"\" class=\"wp-image-8529\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/01_lists_page_en-1024x639.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/01_lists_page_en-300x187.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/01_lists_page_en-768x479.png 768w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/01_lists_page_en-1536x958.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/01_lists_page_en-2048x1278.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>The lists page before localization<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">To follow along you need the <a href=\"https:\/\/dotnet.microsoft.com\/en-us\/download\/dotnet\/10.0\" rel=\"nofollow\">.NET 10 SDK<\/a>. <code>dotnet run -- seed<\/code> creates a demo account with the password demo1234 and three sample lists, and <code>dotnet run<\/code> starts the app at the address it prints.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Everything localization needs, apart from plurals, ships with ASP.NET Core, so there is no package to install yet. Register the services and the middleware in Program.cs.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Program.cs\n\nbuilder.Services.AddLocalization(options =&gt; options.ResourcesPath = \"Resources\");\n\nbuilder.Services.AddRazorPages(options =&gt;\n\n{\n\n&nbsp;&nbsp;&nbsp;&nbsp;\/\/ Everything needs a login except the account pages, the error page\n\n&nbsp;&nbsp;&nbsp;&nbsp;\/\/ and the language switcher.\n\n&nbsp;&nbsp;&nbsp;&nbsp;options.Conventions.AuthorizeFolder(\"\/\");\n\n&nbsp;&nbsp;&nbsp;&nbsp;options.Conventions.AllowAnonymousToFolder(\"\/Account\");\n\n&nbsp;&nbsp;&nbsp;&nbsp;options.Conventions.AllowAnonymousToPage(\"\/Error\");\n\n&nbsp;&nbsp;&nbsp;&nbsp;options.Conventions.AllowAnonymousToPage(\"\/Language\");\n\n})\n\n&nbsp;&nbsp;&nbsp;&nbsp;.AddViewLocalization()\n\n&nbsp;&nbsp;&nbsp;&nbsp;.AddDataAnnotationsLocalization(options =&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;{\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;\/\/ Validation messages and display names come from SharedResource too.\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;options.DataAnnotationLocalizerProvider = (type, factory) =&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;factory.Create(typeof(SharedResource));\n\n&nbsp;&nbsp;&nbsp;&nbsp;});\n\nstring&#091;] supportedCultures = &#091;\"en\", \"fr\"];\n\nbuilder.Services.Configure&lt;RequestLocalizationOptions&gt;(options =&gt;\n\n{\n\n&nbsp;&nbsp;&nbsp;&nbsp;options.SetDefaultCulture(supportedCultures&#091;0])\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;.AddSupportedCultures(supportedCultures)\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;.AddSupportedUICultures(supportedCultures);\n\n});\n\nvar app = builder.Build();\n\n\/\/ ...\n\n\/\/ Before the status code pages: re-executing a request for the error page\n\n\/\/ replaces its query string, and with it a ?culture= choice.\n\napp.UseRequestLocalization();\n\napp.UseStatusCodePagesWithReExecute(\"\/Error\", \"?code={0}\");\n\napp.UseRouting();<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>AddLocalization<\/code> registers <code>IStringLocalizer&lt;T&gt;<\/code> and tells it to look for resource files in the Resources folder. <code>AddViewLocalization<\/code> adds the localizers that Razor views use, and <code>AddDataAnnotationsLocalization<\/code> makes validation messages translatable, which a later section covers. <code>SharedResource<\/code> is an empty class you\u2019ll create in the next section.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Every request runs with two cultures. <code>CultureInfo.CurrentCulture<\/code> controls how dates and numbers are formatted, while <code>CultureInfo.CurrentUICulture<\/code> decides which translations the localizers return, and the two lists of supported cultures above set the choices for each. <code>UseRequestLocalization<\/code> sets both at the start of every request, before anything reads them. It also goes above <code>UseStatusCodePagesWithReExecute<\/code>, which runs the request again for the error page with <code>?code=404<\/code> in place of the original query string, so a <code>?culture=fr<\/code> in the address would otherwise be lost on a 404.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The middleware asks three providers in turn and takes the first supported answer:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>The query string, as in <code>?culture=fr<\/code>.<\/li>\n\n\n\n<li>The <code>.AspNetCore.Culture<\/code> cookie, which the language switcher sets later on.<\/li>\n\n\n\n<li>The browser\u2019s <code>Accept-Language<\/code> header.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">A regional culture falls back to its parent, so a browser that asks for fr-CA gets French. A browser that asks only for languages the app doesn\u2019t support, such as German, gets English, the default culture.<\/p>\n\n\n\n<h2 id=\"Creating-a-shared-.resx-resource-file\" class=\"wp-block-heading\">Creating a shared .resx resource file<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>IStringLocalizer&lt;T&gt;<\/code> finds its resources by the full name of T, minus the app\u2019s assembly name. With <code>ResourcesPath<\/code> set to Resources, a localizer for the class <code>TodoApp.SharedResource<\/code> reads <code>Resources\/SharedResource.resx<\/code>, and on a French request it looks in <code>Resources\/SharedResource.fr.resx<\/code> first. You can use a separate resource file for each page model or view, but a single shared file means you only need to import and export each language once in POEditor. To set that up, create an empty class to name the resource file.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ SharedResource.cs\n\nnamespace TodoApp;\n\n\/\/ Marker class for the app's single resource file, Resources\/SharedResource.resx,\n\n\/\/ and its translations (SharedResource.fr.resx and so on).\n\npublic class SharedResource\n\n{\n\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">ASP.NET Core lets you use the English text itself as the key. When a localizer can\u2019t find a key in any resource file, it returns the key unchanged, so <code>L[\"Your lists\"]<\/code> prints <em>Your lists<\/em> before a single translation exists. The TO-DO app still keeps an English file, <code>SharedResource.resx<\/code>, with every key and its English text. It is the catalog you upload to POEditor, and it is where English wording can differ from the key, as you\u2019ll see with the task status badges.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">.NET has no command that scans your code for strings, and the SDK has no dotnet new template for <code>.resx<\/code> files, although Visual Studio can create one from its Add New Item dialog. Since <code>.resx<\/code> files use plain XML, you can write them by hand, adding an entry whenever you localize a new string in the code. Here are the header and four of the TO-DO app\u2019s entries:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;!-- Resources\/SharedResource.resx --&gt;\n\n&lt;root&gt;\n\n&nbsp;&nbsp;&lt;resheader name=\"resmimetype\"&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&lt;value&gt;text\/microsoft-resx&lt;\/value&gt;\n\n&nbsp;&nbsp;&lt;\/resheader&gt;\n\n&nbsp;&nbsp;&lt;resheader name=\"version\"&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&lt;value&gt;2.0&lt;\/value&gt;\n\n&nbsp;&nbsp;&lt;\/resheader&gt;\n\n&nbsp;&nbsp;&lt;resheader name=\"reader\"&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&lt;value&gt;System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089&lt;\/value&gt;\n\n&nbsp;&nbsp;&lt;\/resheader&gt;\n\n&nbsp;&nbsp;&lt;resheader name=\"writer\"&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&lt;value&gt;System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089&lt;\/value&gt;\n\n&nbsp;&nbsp;&lt;\/resheader&gt;\n\n&nbsp;&nbsp;&lt;data name=\"Your lists\" xml:space=\"preserve\"&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&lt;value&gt;Your lists&lt;\/value&gt;\n\n&nbsp;&nbsp;&lt;\/data&gt;\n\n&nbsp;&nbsp;&lt;data name=\"{count, plural, one {{count, number} task left} other {{count, number} tasks left}}\" xml:space=\"preserve\"&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&lt;value&gt;{count, plural, one {{count, number} task left} other {{count, number} tasks left}}&lt;\/value&gt;\n\n&nbsp;&nbsp;&lt;\/data&gt;\n\n&nbsp;&nbsp;&lt;data name='List \"{0}\" created.' xml:space=\"preserve\"&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&lt;value&gt;List \"{0}\" created.&lt;\/value&gt;\n\n&nbsp;&nbsp;&lt;\/data&gt;\n\n&nbsp;&nbsp;&lt;data name=\"Status: Done\" xml:space=\"preserve\"&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&lt;value&gt;Done&lt;\/value&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&lt;comment&gt;Badge on a single completed task&lt;\/comment&gt;\n\n&nbsp;&nbsp;&lt;\/data&gt;\n\n&lt;\/root&gt;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Each <code>&lt;data&gt; <\/code>element pairs a key, in its name attribute, with a <code>&lt;value&gt;<\/code>. The optional <code>&lt;comment&gt;<\/code> is a note for translators, and POEditor imports it along with the string. POEditor does the same with an XML comment placed just before a <code>&lt;data&gt;<\/code> element, so keep <code>&lt;!-- --&gt;<\/code> notes out of the file unless you mean them for translators. When you build the app, the English file is compiled into <code>TodoApp.dll<\/code> and each translation into a satellite assembly such as <code>fr\/TodoApp.resources.dll<\/code> in the output folder, so a new or edited <code>.resx<\/code> file only takes effect after a rebuild.<\/p>\n\n\n\n<h2 id=\"Using-IStringLocalizer-in-Razor-Pages\" class=\"wp-block-heading\">Using IStringLocalizer in Razor Pages<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Inject a localizer into every page at once from <code>_ViewImports.cshtml<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>@* Pages\/_ViewImports.cshtml *@\n\n@using System.Globalization\n\n@using Microsoft.AspNetCore.Mvc.Localization\n\n@using Microsoft.Extensions.Localization\n\n@using TodoApp\n\n@using TodoApp.Models\n\n@namespace TodoApp.Pages\n\n@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers\n\n@inject IStringLocalizer&lt;SharedResource&gt; L<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Replace the hardcoded strings with calls to <code>L[\"...\"]<\/code>. A key can carry <code>string.Format<\/code> placeholders such as <code>{0}<\/code>, with the values passed after the key, and Razor HTML-encodes the result like any other string, so an <code>&amp;<\/code> or <code>&lt;<\/code> in a translation can\u2019t break the markup. You\u2019ll also need to localize text in attributes such as <code>placeholder<\/code> and <code>aria-label<\/code>. The latter is easy to overlook because it\u2019s read by screen readers rather than displayed on the page.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>@* Pages\/Index.cshtml *@\n\n@{\n\n&nbsp;&nbsp;&nbsp;&nbsp;ViewData&#091;\"Title\"] = L&#091;\"Your lists\"];\n\n&nbsp;&nbsp;&nbsp;&nbsp;\/\/ ...\n\n}\n\n&lt;label asp-for=\"NewList.Name\" class=\"form-label\"&gt;@L&#091;\"New list\"]&lt;\/label&gt;\n\n&lt;input asp-for=\"NewList.Name\" class=\"form-control\" placeholder=\"@L&#091;\"Groceries\"]\" \/&gt;\n\n@* ... *@\n\n&lt;div class=\"card-footer bg-transparent text-body-secondary small\"&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;@L&#091;\"Created on {0}\", Format.Date(list.CreatedAt)]\n\n&lt;\/div&gt;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The navbar\u2019s <code>Signed in as<\/code> line wraps the username in <code>&lt;strong&gt;<\/code>, and with <code>IStringLocalizer<\/code> that tag would come out encoded as text. <code>IHtmlLocalizer<\/code>, one of the services <code>AddViewLocalization<\/code> registers, prints the translation as HTML and still encodes the arguments, so a user named <code>&lt;i&gt;bob&lt;\/i&gt;<\/code> appears literally instead of in italics.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>@* Pages\/Shared\/_Layout.cshtml *@\n\n@inject IHtmlLocalizer&lt;SharedResource&gt; H\n\n@* ... *@\n\n&lt;span class=\"text-body-secondary small text-break\"&gt;@H&#091;\"Signed in as &lt;strong&gt;{0}&lt;\/strong&gt;\", User.Identity.Name!]&lt;\/span&gt;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Page models take <code>IStringLocalizer&lt;SharedResource><\/code> through their constructor. Before localization, the handlers built flash messages using string interpolation or concatenation, for example, <code>$\"Task \\\"{task.Text}\\\" added.\"<\/code>. Now each one is a whole sentence with a placeholder, so translators can put the task name wherever it fits in the French sentence.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Pages\/Lists\/Details.cshtml.cs\n\npublic class DetailsModel(TodoDbContext db, IStringLocalizer&lt;SharedResource&gt; localizer) : AppPageModel\n\n{\n\n&nbsp;&nbsp;&nbsp;&nbsp;\/\/ ...\n\n&nbsp;&nbsp;&nbsp;&nbsp;public async Task&lt;IActionResult&gt; OnPostAddTaskAsync(int id, string? show, TaskInput newTask)\n\n&nbsp;&nbsp;&nbsp;&nbsp;{\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;\/\/ ...\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Flash(localizer&#091;\"Task \\\"{0}\\\" added.\", task.Text]);\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;return RedirectToPage(new { id, show });\n\n&nbsp;&nbsp;&nbsp;&nbsp;}\n\n&nbsp;&nbsp;&nbsp;&nbsp;\/\/ ...\n\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">With the English text as the key, two uses of the same English word share one entry, and <code>.resx<\/code> has no context field like gettext\u2019s <code>msgctxt<\/code> to tell them apart. The list page uses Done both for a filter tab and for the badge on a completed task, but French needs the plural Termin\u00e9es for the tab and the singular Termin\u00e9e for one task. The badges therefore get keys of their own, <code>Status: Done<\/code> and <code>Status: Open<\/code>, with the English values Done and Open (the first is in the <code>SharedResource.resx<\/code> excerpt above), while the filter tabs keep <code>L[\"Done\"]<\/code> and <code>L[\"Open\"]<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>@* Pages\/Lists\/Details.cshtml *@\n\n&lt;span class=\"badge rounded-pill mt-2 @(task.Done ? \"text-bg-success\" : \"text-bg-secondary\")\"&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;@(task.Done ? L&#091;\"Status: Done\"] : L&#091;\"Status: Open\"])\n\n&lt;\/span&gt;<\/code><\/pre>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\"><strong>Quick tip<\/strong>: The unlocalized app asked for confirmation with an inline <code>onsubmit=\"return confirm('Delete this list and everything in it?');\"<\/code>. If you put <code>@L[...]<\/code> inside those quotes, the French text <em>Supprimer cette liste et tout ce qu&#8217;elle contient ?<\/em> ends the JavaScript string at its apostrophe. The browser reports a syntax error, shows no dialog and submits the form, which deletes the list without asking.<\/p>\n<\/blockquote>\n\n\n\n<p class=\"wp-block-paragraph\">The TO-DO app puts the translated text in a data-confirm attribute instead, as in <code>&lt;form method=\"post\" ... data-confirm=\"@L[\"Delete this task?\"]\"&gt;<\/code>, where Razor encodes it like any other attribute value. A small script reads the attribute on submit:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ wwwroot\/js\/site.js\n\n\/\/ Asks before submitting a form that has a data-confirm attribute. The text\n\n\/\/ comes from the attribute, so a translation with an apostrophe in it\n\n\/\/ cannot break the script.\n\ndocument.querySelectorAll('form&#091;data-confirm]').forEach(form =&gt; {\n\n&nbsp;&nbsp;&nbsp;&nbsp;form.addEventListener('submit', event =&gt; {\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;if (!confirm(form.dataset.confirm)) {\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;event.preventDefault();\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;}\n\n&nbsp;&nbsp;&nbsp;&nbsp;});\n\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Load it at the end of <code>_Layout.cshtml<\/code>, after the Bootstrap bundle, with <code>&lt;script src=\"~\/js\/site.js\" asp-append-version=\"true\"&gt;&lt;\/script&gt;<\/code>.<\/p>\n\n\n\n<h2 id=\"Translating-validation-messages-and-display-names\" class=\"wp-block-heading\">Translating validation messages and display names<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The forms use data annotations, and <code>AddDataAnnotationsLocalization<\/code> in Program.cs sends their text to SharedResource as well, through the <code>DataAnnotationLocalizerProvider<\/code> option. Without that option, ASP.NET Core would look for a separate resource file for each model class. Display names are translated automatically, so <code>[Display(Name = \"Username\")]<\/code> gives the French login form a Nom d\u2019utilisateur label.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The built-in error messages are not translated, though. With a bare <code>[Required]<\/code> on the task field, the French page reports The T\u00e2che field is required., a translated field name inside an English sentence. ASP.NET Core only looks up a localized validation message when the attribute has its own <code>ErrorMessage<\/code> value. Add one to each attribute, then include the message in the <code>.resx<\/code> file like any other key.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Pages\/FormModels.cs\n\npublic class ListInput\n\n{\n\n&nbsp;&nbsp;&nbsp;&nbsp;&#091;Required(ErrorMessage = \"The {0} field is required.\")]\n\n&nbsp;&nbsp;&nbsp;&nbsp;&#091;StringLength(100, ErrorMessage = \"The {0} field can be at most {1} characters long.\")]\n\n&nbsp;&nbsp;&nbsp;&nbsp;&#091;Display(Name = \"List name\")]\n\n&nbsp;&nbsp;&nbsp;&nbsp;public string Name\n\n&nbsp;&nbsp;&nbsp;&nbsp;{\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;get =&gt; name;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;\/\/ Trimmed as it is bound, so the length check sees what gets stored.\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;set =&gt; name = value?.Trim() ?? \"\";\n\n&nbsp;&nbsp;&nbsp;&nbsp;}\n\n&nbsp;&nbsp;&nbsp;&nbsp;private string name = \"\";\n\n}\n\npublic class TaskInput\n\n{\n\n&nbsp;&nbsp;&nbsp;&nbsp;&#091;Required(ErrorMessage = \"The {0} field is required.\")]\n\n&nbsp;&nbsp;&nbsp;&nbsp;&#091;StringLength(200, ErrorMessage = \"The {0} field can be at most {1} characters long.\")]\n\n&nbsp;&nbsp;&nbsp;&nbsp;&#091;Display(Name = \"Task\")]\n\n&nbsp;&nbsp;&nbsp;&nbsp;public string Text\n\n&nbsp;&nbsp;&nbsp;&nbsp;{\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;get =&gt; text;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;\/\/ Trimmed as it is bound, so the length check sees what gets stored.\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;set =&gt; text = value?.Trim() ?? \"\";\n\n&nbsp;&nbsp;&nbsp;&nbsp;}\n\n&nbsp;&nbsp;&nbsp;&nbsp;private string text = \"\";\n\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>{0}<\/code> is the translated display name and <code>{1}<\/code> the maximum length. For a <code>StringLength<\/code> with a minimum, such as the registration form\u2019s username, <code>{2}<\/code> holds the minimum, so The <code>{0}<\/code> field must be between <code>{2}<\/code> and <code>{1}<\/code> characters long. becomes Le champ Nom d\u2019utilisateur doit contenir entre 3 et 30 caract\u00e8res. Errors that a handler adds itself go through the localizer directly, as in <code>ModelState.AddModelError(string.Empty, localizer[\"That username and password do not match.\"])<\/code>.<\/p>\n\n\n\n<h2 id=\"Handling-plurals-with-ICU-MessageFormat\" class=\"wp-block-heading\">Handling plurals with ICU MessageFormat<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>.resx<\/code> files have no plural forms, and <code>IStringLocalizer<\/code> has no plural method, so branches such as the unlocalized app\u2019s <code>list.OpenCount == 1 ? \"1 task left\" : $\"{Format.Number(list.OpenCount)} tasks left\"<\/code> can\u2019t be translated as they are. They also build English grammar into the code: in French, 0 takes the singular, so an empty list should say 0 t\u00e2che restante, which a <code>count == 1<\/code> check can never produce.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The TO-DO app stores each plural string as a single <a href=\"https:\/\/www.nuget.org\/packages\/MessageFormat\" rel=\"nofollow\">ICU MessageFormat<\/a> message in the <code>.resx<\/code> file and formats it with the <code>MessageFormat<\/code> package (version 8.0.0), which you add with <code>dotnet add package MessageFormat<\/code>. The package uses CLDR plural rules to choose the right form for the specified culture. A small extension method on <code>IStringLocalizer<\/code> is all the views need to call it.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ LocalizerExtensions.cs\n\nusing System.Globalization;\n\nusing Jeffijoe.MessageFormat;\n\nusing Microsoft.Extensions.Localization;\n\nnamespace TodoApp;\n\npublic static class LocalizerExtensions\n\n{\n\n&nbsp;&nbsp;&nbsp;&nbsp;private static readonly MessageFormatter Formatter = new();\n\n&nbsp;&nbsp;&nbsp;&nbsp;\/\/ Looks up an ICU plural message, such as\n\n&nbsp;&nbsp;&nbsp;&nbsp;\/\/ \"{count, plural, one {{count, number} list} other {{count, number} lists}}\",\n\n&nbsp;&nbsp;&nbsp;&nbsp;\/\/ and picks the plural form the current language uses for this count.\n\n&nbsp;&nbsp;&nbsp;&nbsp;public static string Plural(this IStringLocalizer localizer, string key, int count) =&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Formatter.FormatMessage(\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;localizer&#091;key],\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;new Dictionary&lt;string, object?&gt; { &#091;\"count\"] = count },\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;CultureInfo.CurrentUICulture);\n\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">In a view, the ICU message is the key, and the count goes in as the second argument:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>@* Pages\/Index.cshtml *@\n\n@L.Plural(\"{count, plural, one {{count, number} task left} other {{count, number} tasks left}}\", list.OpenCount)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">ICU\u2019s usual shorthand for the number inside a branch is <code>#<\/code>, but MessageFormat.NET prints <code>#<\/code> without digit grouping, as 1234. The TO-DO app writes <code>{count, number}<\/code> instead, which formats the count for the culture the helper passes in, as 1,234 in English and 1 234 in French. The French translation keeps the same structure with French text in each branch, and it has one more branch than English, because French has a many category, which CLDR uses for exact multiples of a million, such as 1 000 000 de t\u00e2ches.<\/p>\n\n\n\n<h2 id=\"Adding-a-language-switcher\" class=\"wp-block-heading\">Adding a language switcher<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>?culture=fr<\/code> query parameter changes the language for one request, but it doesn\u2019t remember the choice when the visitor navigates to another page. To make it stick, the TO-DO app stores it in the cookie that <code>CookieRequestCultureProvider<\/code> reads, using <code>MakeCookieValue<\/code> to build the value, which comes out as <code>c=fr|uic=fr<\/code>. A Razor Page handles the switcher\u2019s form submission and redirects the visitor back to the page they were viewing.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Pages\/Language.cshtml.cs\n\nusing Microsoft.AspNetCore.Diagnostics;\n\nusing Microsoft.AspNetCore.Localization;\n\nusing Microsoft.AspNetCore.Mvc;\n\nusing Microsoft.AspNetCore.Mvc.RazorPages;\n\nusing Microsoft.AspNetCore.WebUtilities;\n\nusing Microsoft.Extensions.Options;\n\nnamespace TodoApp.Pages;\n\n\/\/ POST only: the language buttons in the navbar. Stores the choice in the\n\n\/\/ culture cookie that CookieRequestCultureProvider reads on every request.\n\n\/\/ No antiforgery check: a tab whose token is stale (after logging in or out\n\n\/\/ elsewhere) can still switch language, and the cookie only picks a language.\n\n&#091;IgnoreAntiforgeryToken]\n\npublic class LanguageModel(IOptions&lt;RequestLocalizationOptions&gt; localization) : PageModel\n\n{\n\n&nbsp;&nbsp;&nbsp;&nbsp;public IActionResult OnGet() =&gt; RedirectToPage(\"\/Index\");\n\n&nbsp;&nbsp;&nbsp;&nbsp;public IActionResult OnPost(string culture, string? returnUrl)\n\n&nbsp;&nbsp;&nbsp;&nbsp;{\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;var supported = localization.Value.SupportedUICultures!.Any(c =&gt; c.Name == culture);\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;if (supported)\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Response.Cookies.Append(\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;CookieRequestCultureProvider.DefaultCookieName,\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;new CookieOptions { Expires = DateTimeOffset.UtcNow.AddYears(1), IsEssential = true });\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;}\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;\/\/ Only addresses on this site, and only ASCII ones, as the switcher sends.\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;var local = Url.IsLocalUrl(returnUrl) &amp;&amp; returnUrl.All(char.IsAscii);\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;return LocalRedirect(local ? returnUrl! : \"\/\");\n\n&nbsp;&nbsp;&nbsp;&nbsp;}\n\n&nbsp;&nbsp;&nbsp;&nbsp;\/\/ ...\n\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>Language.cshtml<\/code> page next to it contains only <code>@page<\/code> and its <code>@model<\/code> line. Since the app requires a login for every page except the account pages and the error page, the <code>AllowAnonymousToPage(\"\/Language\")<\/code> call in <code>Program.cs<\/code> keeps the switcher working on the login form, and <code>Url.IsLocalUrl<\/code> stops the return address from sending anyone to another site.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The address to come back to needs two adjustments. A <code>?culture=fr<\/code> left in it would override the new cookie, so after clicking English the visitor would land on the same page, still in French. And an error page runs as <code>\/Error<\/code>, so the original address has to come from the feature ASP.NET Core leaves on the request: <code>IStatusCodeReExecuteFeature<\/code> on a status code page such as the 404 page, and <code>IExceptionHandlerPathFeature<\/code> on the page shown after an unhandled exception. A static helper on the same class handles both:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Pages\/Language.cshtml.cs\n\n&nbsp;&nbsp;&nbsp;&nbsp;\/\/ The page to come back to: the address in the browser, even on an error page,\n\n&nbsp;&nbsp;&nbsp;&nbsp;\/\/ minus ?culture= and ?ui-culture=, which would override the new cookie.\n\n&nbsp;&nbsp;&nbsp;&nbsp;public static string ReturnUrlFor(HttpContext context)\n\n&nbsp;&nbsp;&nbsp;&nbsp;{\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;var request = context.Request;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;var path = request.PathBase.Add(request.Path);\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;var queryString = request.QueryString.Value;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;\/\/ An error page runs as \/Error; these features keep the original address.\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;if (context.Features.Get&lt;IStatusCodeReExecuteFeature&gt;() is { } reExecute)\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;path = new PathString(reExecute.OriginalPathBase).Add(new PathString(reExecute.OriginalPath));\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;queryString = reExecute.OriginalQueryString;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;}\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;else if (context.Features.Get&lt;IExceptionHandlerPathFeature&gt;() is { } exception)\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;path = request.PathBase.Add(new PathString(exception.Path));\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;}\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;var kept = QueryHelpers.ParseQuery(queryString)\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;.Where(q =&gt; !q.Key.Equals(\"culture\", StringComparison.OrdinalIgnoreCase)\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&amp;&amp; !q.Key.Equals(\"ui-culture\", StringComparison.OrdinalIgnoreCase));\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;return path + QueryString.Create(kept);\n\n&nbsp;&nbsp;&nbsp;&nbsp;}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">In the layout, the switcher is one form with a button per language, each labeled in its own language, and the lang attribute on <code>&lt;html&gt;<\/code> follows the UI culture.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>@* Pages\/Shared\/_Layout.cshtml *@\n\n@inject IHtmlLocalizer&lt;SharedResource&gt; H\n\n@{\n\n&nbsp;&nbsp;&nbsp;&nbsp;var language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;\n\n&nbsp;&nbsp;&nbsp;&nbsp;var returnUrl = LanguageModel.ReturnUrlFor(Context);\n\n}\n\n&lt;!DOCTYPE html&gt;\n\n&lt;html lang=\"@language\"&gt;\n\n@* ... *@\n\n&lt;form method=\"post\" asp-page=\"\/Language\" asp-route-returnUrl=\"@returnUrl\" class=\"m-0\"&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&lt;div class=\"btn-group btn-group-sm\" role=\"group\" aria-label=\"@L&#091;\"Language\"]\"&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&lt;button type=\"submit\" name=\"culture\" value=\"en\" lang=\"en\"\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;class=\"btn @(language == \"en\" ? \"btn-secondary\" : \"btn-outline-secondary\")\"&gt;English&lt;\/button&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&lt;button type=\"submit\" name=\"culture\" value=\"fr\" lang=\"fr\"\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;class=\"btn @(language == \"fr\" ? \"btn-secondary\" : \"btn-outline-secondary\")\"&gt;Fran\u00e7ais&lt;\/button&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&lt;\/div&gt;\n\n&lt;\/form&gt;<\/code><\/pre>\n\n\n\n<h2 id=\"Formatting-dates-and-numbers-with-CultureInfo\" class=\"wp-block-heading\">Formatting dates and numbers with CultureInfo<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The localizers only cover text. The unlocalized <code>Format<\/code> class passed fixed patterns such as <code>M\/d\/yyyy<\/code> with <code>CultureInfo.InvariantCulture<\/code>, so French pages would still show <code>9\/24\/2026<\/code>. Use .NET\u2019s standard format strings with the current culture instead.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Format.cs\n\n\/\/ Date and number formatting in the current request's culture.\n\n\/\/ Dates are stored in UTC and shown in the server's local time zone.\n\npublic static class Format\n\n{\n\n&nbsp;&nbsp;&nbsp;&nbsp;\/\/ 9\/24\/2026 in English, 24\/09\/2026 in French\n\n&nbsp;&nbsp;&nbsp;&nbsp;public static string Date(DateTime utc) =&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;ToLocal(utc).ToString(\"d\", CultureInfo.CurrentCulture);\n\n&nbsp;&nbsp;&nbsp;&nbsp;\/\/ 9\/24\/2026 3:07 PM in English, 24\/09\/2026 15:07 in French\n\n&nbsp;&nbsp;&nbsp;&nbsp;public static string DateAndTime(DateTime utc) =&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;ToLocal(utc).ToString(\"g\", CultureInfo.CurrentCulture);\n\n&nbsp;&nbsp;&nbsp;&nbsp;\/\/ 1,234 in English, 1 234 in French\n\n&nbsp;&nbsp;&nbsp;&nbsp;public static string Number(int value) =&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;value.ToString(\"N0\", CultureInfo.CurrentCulture);\n\n&nbsp;&nbsp;&nbsp;&nbsp;\/\/ ...\n\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>d<\/code> is the culture\u2019s short date, g its short date and time, and <code>N0<\/code> a whole number with the culture\u2019s digit grouping, which in French is a narrow no-break space. All three follow <code>CurrentCulture<\/code>, while the localizers and the plural helper follow <code>CurrentUICulture<\/code>. The <code>{0}%<\/code> done progress text is a translated string, so the French translator writes <code>{0} % termin\u00e9<\/code>, with the space that French typography puts before the percent sign.<\/p>\n\n\n\n<h2 id=\"Translating-the-.resx-file-in-POEditor\" class=\"wp-block-heading\">Translating the .resx file in POEditor<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">In POEditor, create a project for the app from the <strong>+<\/strong> button on the Dashboard, and name it TO-DO app (ASP.NET Core). POEditor opens the new project, where you add English and French as its languages.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"637\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/02_project_languages-1024x637.png\" alt=\"\" class=\"wp-image-8531\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/02_project_languages-1024x637.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/02_project_languages-300x187.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/02_project_languages-768x478.png 768w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/02_project_languages-1536x956.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/02_project_languages-2048x1275.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><br><em>English and French in the new project, before any import<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Open Import, choose <code>Resources\/SharedResource.resx<\/code> on the Import Terms page, keep Also import translations to a language switched on, and choose English from the language list. POEditor recognizes the format from the <code>.resx<\/code> extension, so there is no format to pick. Click Import to project.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"977\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/03_import_resx-1-1024x977.png\" alt=\"\" class=\"wp-image-8532\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/03_import_resx-1-1024x977.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/03_import_resx-1-300x286.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/03_import_resx-1-767x732.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/03_import_resx-1-1536x1466.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/03_import_resx-1-2048x1955.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>Importing SharedResource.resx, with its values going into English<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Each name becomes a term and each <code>&lt;value&gt;<\/code> its English translation, so the TO-DO app\u2019s 78 strings arrive as 78 terms with English already at 100%. The import doesn\u2019t set a reference language, though, and the Status: Done term needs one to show translators its English text. Click the gear icon to open the project settings, click <em>Edit Details<\/em>, set DEFAULT REFERENCE LANGUAGE to English, and click <em>Save project details<\/em>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Open the French language and start translating the terms. The editor shows the English text of each term in large type, with its key below in small grey type. Keep placeholders such as <code>{0}<\/code> exactly as written, though you can move them anywhere in the French sentence. If a translation drops or changes one, QA Checks flag it when you save. Terms that had a <code>&lt;comment&gt;<\/code> in the <code>.resx<\/code> file have a filled-in comment icon, and clicking it opens a Comments panel with the note, such as Badge on a single completed task.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"619\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/04_french_translations-1024x619.png\" alt=\"\" class=\"wp-image-8533\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/04_french_translations-1024x619.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/04_french_translations-300x181.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/04_french_translations-767x464.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/04_french_translations-1536x929.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/04_french_translations-2048x1238.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>French translations, with each term\u2019s English text above its key<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">POEditor recognizes the ICU plural messages and shows an eye icon labeled ICU editor in the translation box of each one. Once a term has a translation, the icon only appears when you click into the box. Clicking the icon opens a dialog with the English form on the left and a tab for each French plural form, ONE, MANY and OTHER, so you can translate the forms one at a time and click Save to write the full ICU message back. Fill in all three tabs.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"452\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/05_icu_editor-1024x452.png\" alt=\"\" class=\"wp-image-8534\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/05_icu_editor-1024x452.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/05_icu_editor-768x339.png 768w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/05_icu_editor-300x132.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/05_icu_editor-1536x678.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/05_icu_editor-2048x904.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>The ICU editor, with one tab for each French plural form<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">If you would rather review a first draft than translate from scratch, you can pre-fill the French terms with Automatic Translation (Google Translate, DeepL or Azure AI Translator), which is built in, or with AI translation from providers such as OpenAI or Anthropic\u2019s Claude once you add your own API key. Check the ICU plural messages by hand afterwards, because automatic translation doesn\u2019t always handle ICU syntax well.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Once you\u2019ve finished translating, open Export. On the Export Terms and Translations page, set FILE FORMAT to .NET RESX (.resx) and click Export File. If some terms are still untranslated, switch on Advanced export options first and set EXPORT to Translated. By default the file includes an untranslated term with an empty <code>&lt;value&gt;<\/code>, and ASP.NET Core displays an empty value as blank text, while a term that is missing from the French file falls back to the English in <code>SharedResource.resx<\/code>.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"809\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/06_export_resx-1024x809.png\" alt=\"\" class=\"wp-image-8535\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/06_export_resx-1024x809.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/06_export_resx-300x237.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/06_export_resx-767x606.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/06_export_resx-1536x1214.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/06_export_resx-2048x1618.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>Exporting the French translations as a .NET RESX file<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The download is named after the project and the language, <code>TO-DO_app_ASP.NET_Core_French.resx<\/code>. Save it as <code>Resources\/SharedResource.fr.resx<\/code>. It contains the same keys as the English file, with the French text in each <code>&lt;value&gt;<\/code> and the comments kept.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;!-- Resources\/SharedResource.fr.resx --&gt;\n\n&nbsp;&nbsp;&lt;data name=\"Status: Done\" xml:space=\"preserve\"&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&lt;value&gt;Termin\u00e9e&lt;\/value&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&lt;comment&gt;Badge on a single completed task&lt;\/comment&gt;\n\n&nbsp;&nbsp;&lt;\/data&gt;\n\n&nbsp;&nbsp;&lt;data name=\"{count, plural, one {{count, number} task left} other {{count, number} tasks left}}\" xml:space=\"preserve\"&gt;\n\n&nbsp;&nbsp;&nbsp;&nbsp;&lt;value&gt;{count, plural, one {{count, number} t\u00e2che restante} many {{count, number} de t\u00e2ches restantes} other {{count, number} t\u00e2ches restantes}}&lt;\/value&gt;\n\n&nbsp;&nbsp;&lt;\/data&gt;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Run dotnet run again so the build picks up the new file, then click Fran\u00e7ais in the navbar.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"642\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/07_lists_page_fr-1024x642.png\" alt=\"\" class=\"wp-image-8536\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/07_lists_page_fr-1024x642.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/07_lists_page_fr-300x188.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/07_lists_page_fr-767x481.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/07_lists_page_fr-1536x963.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/07_lists_page_fr-2048x1284.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>The lists page in French, with plural task counts and French dates<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">On a list page, the badge of a completed task shows its own translation, Termin\u00e9e, while the filter tab above it reads Termin\u00e9es.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"755\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/08_list_page_fr-1024x755.png\" alt=\"\" class=\"wp-image-8537\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/08_list_page_fr-1024x755.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/08_list_page_fr-300x221.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/08_list_page_fr-767x566.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/08_list_page_fr-1536x1133.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/08_list_page_fr-2048x1511.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>A list in French, with the task status badge and the filter tabs<\/em><\/figcaption><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Wrapping up<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Whenever you add a new string to the app, add it to <code>SharedResource.resx<\/code> and import the file into POEditor again. Also import translations to a language starts switched off once the project has terms, so switch it on and choose English, or the new terms arrive without their English text. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The import adds only the new terms, which appear untranslated in French, and the existing ones keep their translations, so export with Translated until French catches up. Because the keys are the English text, rewording a string creates a new key. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">After that import, POEditor opens an Obsolete Terms page listing the old term, which you can tick and remove with Delete Selected, or keep with Keep all and go to terms list and delete later from the Terms page. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you need to do this for every release, you can use the <a href=\"\/docs\/api\">POEditor API<\/a> or a <a href=\"\/kb\/localization-file-management-with-github-bitbucket-and-gitlab-integrations\">code hosting integration<\/a> to upload SharedResource.resx and download the translated files automatically.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Create a free POEditor account and import your own .resx file to start translating your ASP.NET Core app.<\/p>\n\n\n<div class=\"call-action my-4 d-flex justify-content-between align-items-md-center gap-4 flex-column flex-lg-row\"><div><h3 class=\"fs-4\">Ready to power up localization?<\/h3><span class=\"fs-6\">Subscribe to the POEditor platform today!<\/span><\/div><a class=\"btn btn-b-primary d-flex align-items-center justify-content-center px-4 py-3 flex-shrink-0\" \n\t\t\t\t\thref=\"https:\/\/poeditor.com\/pricing\/?utm_source=blog&#038;utm_medium=btn&#038;utm_campaign=cta_pricing\">See pricing<\/a><\/div>\n\n\n\n<h4 class=\"wp-block-heading\">For other stacks in this series, see these guides:<\/h4>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"\/blog\/cakephp-localization\/\">CakePHP Localization: How to Translate a CakePHP App with i18n<\/a><\/li>\n\n\n\n<li><a href=\"\/blog\/django-i18n\/\">Django i18n: How to translate and localize your app<\/a><\/li>\n\n\n\n<li><a href=\"\/blog\/python-gettext-localization\/\">Python gettext localization: How to translate a Python app<\/a><\/li>\n\n\n\n<li><a href=\"\/blog\/node-js-localization\/\">Node.js localization: How to translate an express app with i18n-node<\/a><\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Frequently asked questions<\/h2>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary>Should .resx keys be the English text or identifiers?<\/summary>\n<p class=\"wp-block-paragraph\">ASP.NET Core supports both. With the English text as the key, the localizer returns the key itself when no translation exists, so the app shows readable English before you write any resource file, and translators see the real sentence as the term in POEditor. The downside is that changing the English wording also changes the key, and two uses of the same word share a translation unless you give them separate keys. Identifiers such as Lists.Title survive rewording and keep every use separate, but an untranslated identifier shows up on the page as the identifier, so you need a complete English .resx file from the start. In POEditor, set English as the default reference language either way, so translators see English text next to each key.<\/p>\n<\/details>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary>Can I keep one .resx file per page instead of a shared file?<\/summary>\n<p class=\"wp-block-paragraph\">Yes. Inject IViewLocalizer into a view, and it looks up a resource file named after the view\u2019s path: for Pages\/Index.cshtml with ResourcesPath set to Resources, the French file is Resources\/Pages\/Index.fr.resx. Page models do the same with IStringLocalizer&lt;IndexModel&gt;, which reads a file named after the class. Per-page files keep each file short, but each one is a separate import and export in POEditor, so a shared file is simpler to keep in sync when one team translates the whole app.<\/p>\n<\/details>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary>Why does a page still show English after I add the French file?<\/summary>\n<p class=\"wp-block-paragraph\">First, check whether the request is using French. A culture that isn\u2019t in SupportedUICultures is ignored, so the next source in line decides, with the default culture as the last resort, and ?culture=fr in the address bar is the quickest test. Then check the key: a translation is found only when its name matches the key in the code exactly, including punctuation and capital letters, and a key that is missing from the French file falls back to English. Finally, the French file is compiled into a satellite assembly, so the app has to be rebuilt before a new or changed file takes effect. A translation that shows as blank text instead of English is a different problem: the French file contains that key with an empty value, which happens when you export untranslated terms from POEditor.<\/p>\n<\/details>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary>Why does Razor output &amp;#xE9; instead of \u00e9?<\/summary>\n<p class=\"wp-block-paragraph\">Razor\u2019s HTML encoder only lets basic Latin characters through by default and writes everything else as a numeric character reference, so Cr\u00e9er arrives in the HTML as Cr&amp;#xE9;er. Browsers display both the same way. If you want readable HTML source, allow all Unicode ranges in Program.cs with builder.Services.Configure&lt;WebEncoderOptions&gt;(options =&gt; options.TextEncoderSettings = new TextEncoderSettings(UnicodeRanges.All));, using the Microsoft.Extensions.WebEncoders, System.Text.Encodings.Web and System.Text.Unicode namespaces. Characters that matter for HTML, such as &lt; and &amp;, are still encoded, and so are no-break spaces.<\/p>\n<\/details>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary>Can I use PO files instead of .resx?<\/summary>\n<p class=\"wp-block-paragraph\">Yes, with the OrchardCore.Localization.Core package, which Microsoft\u2019s documentation describes in <a href=\"https:\/\/learn.microsoft.com\/aspnet\/core\/fundamentals\/portable-object-localization\" rel=\"nofollow\">Configure portable object localization<\/a> in ASP.NET Core. You register it with AddPortableObjectLocalization() in place of AddLocalization(), keep using IStringLocalizer and IViewLocalizer, and get plural support through a Plural extension method, since PO files store plural forms natively. Orchard Core is not a Microsoft product, and Microsoft does not support it. POEditor imports and exports PO files as well, so the translation workflow stays the same.<\/p>\n<\/details>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n","protected":false},"excerpt":{"rendered":"<p>ASP.NET Core is Microsoft\u2019s open-source framework for building web apps and APIs in C#. It runs on Windows, Linux and macOS and includes built-in support for localization. If your app is available in several languages, you\u2019ll need to account for more than translated text. Dates, numbers and other values should follow the conventions of each [&hellip;]<\/p>\n","protected":false},"author":6,"featured_media":0,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[3],"tags":[],"class_list":["post-8528","post","type-post","status-publish","format-standard","hentry","category-tutorials"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.7 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>ASP.NET Core localization: How to translate a Razor Pages app with .resx files - POEditor Blog<\/title>\n<meta name=\"description\" content=\"Learn how ASP.NET Core localization works and how to build multilingual web apps, configure cultures, manage resource files, and translate.\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/poeditor.com\/blog\/asp-net-core-localization\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"ASP.NET Core localization: How to translate a Razor Pages app with .resx files - POEditor Blog\" \/>\n<meta property=\"og:description\" content=\"Learn how ASP.NET Core localization works and how to build multilingual web apps, configure cultures, manage resource files, and translate.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/poeditor.com\/blog\/asp-net-core-localization\/\" \/>\n<meta property=\"og:site_name\" content=\"POEditor Blog\" \/>\n<meta property=\"article:publisher\" content=\"https:\/\/www.facebook.com\/POEditor\" \/>\n<meta property=\"article:published_time\" content=\"2026-10-26T06:15:00+00:00\" \/>\n<meta property=\"article:modified_time\" content=\"2026-10-26T08:54:32+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/ASP.NET-Core-localization.png\" \/>\n\t<meta property=\"og:image:width\" content=\"1920\" \/>\n\t<meta property=\"og:image:height\" content=\"917\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/png\" \/>\n<meta name=\"author\" content=\"POEditor\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:creator\" content=\"@poeditor\" \/>\n<meta name=\"twitter:site\" content=\"@poeditor\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"POEditor\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"16 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/asp-net-core-localization\\\/#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/asp-net-core-localization\\\/\"},\"author\":{\"name\":\"POEditor\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#\\\/schema\\\/person\\\/db77eb2d09539eaac83dcd8f2af06b17\"},\"headline\":\"ASP.NET Core localization: How to translate a Razor Pages app with .resx files\",\"datePublished\":\"2026-10-26T06:15:00+00:00\",\"dateModified\":\"2026-10-26T08:54:32+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/asp-net-core-localization\\\/\"},\"wordCount\":3405,\"publisher\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#organization\"},\"image\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/asp-net-core-localization\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/10\\\/ASP.NET-Core-localization-1024x489.png\",\"articleSection\":[\"Tutorials\"],\"inLanguage\":\"en-US\"},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/asp-net-core-localization\\\/\",\"url\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/asp-net-core-localization\\\/\",\"name\":\"ASP.NET Core localization: How to translate a Razor Pages app with .resx files - POEditor Blog\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/asp-net-core-localization\\\/#primaryimage\"},\"image\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/asp-net-core-localization\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/10\\\/ASP.NET-Core-localization-1024x489.png\",\"datePublished\":\"2026-10-26T06:15:00+00:00\",\"dateModified\":\"2026-10-26T08:54:32+00:00\",\"description\":\"Learn how ASP.NET Core localization works and how to build multilingual web apps, configure cultures, manage resource files, and translate.\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/asp-net-core-localization\\\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/poeditor.com\\\/blog\\\/asp-net-core-localization\\\/\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/asp-net-core-localization\\\/#primaryimage\",\"url\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/10\\\/ASP.NET-Core-localization.png\",\"contentUrl\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/10\\\/ASP.NET-Core-localization.png\",\"width\":1920,\"height\":917,\"caption\":\"ASP.NET Core localization\"},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/asp-net-core-localization\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"ASP.NET Core localization: How to translate a Razor Pages app with .resx files\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#website\",\"url\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/\",\"name\":\"POEditor Blog\",\"description\":\"All about translation and localization management\",\"publisher\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#organization\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":\"Organization\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#organization\",\"name\":\"POEditor\",\"url\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/\",\"logo\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#\\\/schema\\\/logo\\\/image\\\/\",\"url\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/wp-content\\\/uploads\\\/2019\\\/11\\\/logo_head_512_transparent.png\",\"contentUrl\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/wp-content\\\/uploads\\\/2019\\\/11\\\/logo_head_512_transparent.png\",\"width\":512,\"height\":512,\"caption\":\"POEditor\"},\"image\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#\\\/schema\\\/logo\\\/image\\\/\"},\"sameAs\":[\"https:\\\/\\\/www.facebook.com\\\/POEditor\",\"https:\\\/\\\/x.com\\\/poeditor\",\"https:\\\/\\\/www.linkedin.com\\\/company\\\/poeditor\\\/\",\"https:\\\/\\\/www.youtube.com\\\/channel\\\/UCXAk1u8N49VRMAqNneENCFA\"]},{\"@type\":\"Person\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#\\\/schema\\\/person\\\/db77eb2d09539eaac83dcd8f2af06b17\",\"name\":\"POEditor\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/95bca2c465fe6134f210cb2f2fbed4f71bf37833fb285624320e808c865695c1?s=96&d=mm&r=g\",\"url\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/95bca2c465fe6134f210cb2f2fbed4f71bf37833fb285624320e808c865695c1?s=96&d=mm&r=g\",\"contentUrl\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/95bca2c465fe6134f210cb2f2fbed4f71bf37833fb285624320e808c865695c1?s=96&d=mm&r=g\",\"caption\":\"POEditor\"},\"url\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/author\\\/poeditor\\\/\"}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"ASP.NET Core localization: How to translate a Razor Pages app with .resx files - POEditor Blog","description":"Learn how ASP.NET Core localization works and how to build multilingual web apps, configure cultures, manage resource files, and translate.","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/poeditor.com\/blog\/asp-net-core-localization\/","og_locale":"en_US","og_type":"article","og_title":"ASP.NET Core localization: How to translate a Razor Pages app with .resx files - POEditor Blog","og_description":"Learn how ASP.NET Core localization works and how to build multilingual web apps, configure cultures, manage resource files, and translate.","og_url":"https:\/\/poeditor.com\/blog\/asp-net-core-localization\/","og_site_name":"POEditor Blog","article_publisher":"https:\/\/www.facebook.com\/POEditor","article_published_time":"2026-10-26T06:15:00+00:00","article_modified_time":"2026-10-26T08:54:32+00:00","og_image":[{"width":1920,"height":917,"url":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/ASP.NET-Core-localization.png","type":"image\/png"}],"author":"POEditor","twitter_card":"summary_large_image","twitter_creator":"@poeditor","twitter_site":"@poeditor","twitter_misc":{"Written by":"POEditor","Est. reading time":"16 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/poeditor.com\/blog\/asp-net-core-localization\/#article","isPartOf":{"@id":"https:\/\/poeditor.com\/blog\/asp-net-core-localization\/"},"author":{"name":"POEditor","@id":"https:\/\/poeditor.com\/blog\/#\/schema\/person\/db77eb2d09539eaac83dcd8f2af06b17"},"headline":"ASP.NET Core localization: How to translate a Razor Pages app with .resx files","datePublished":"2026-10-26T06:15:00+00:00","dateModified":"2026-10-26T08:54:32+00:00","mainEntityOfPage":{"@id":"https:\/\/poeditor.com\/blog\/asp-net-core-localization\/"},"wordCount":3405,"publisher":{"@id":"https:\/\/poeditor.com\/blog\/#organization"},"image":{"@id":"https:\/\/poeditor.com\/blog\/asp-net-core-localization\/#primaryimage"},"thumbnailUrl":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/ASP.NET-Core-localization-1024x489.png","articleSection":["Tutorials"],"inLanguage":"en-US"},{"@type":"WebPage","@id":"https:\/\/poeditor.com\/blog\/asp-net-core-localization\/","url":"https:\/\/poeditor.com\/blog\/asp-net-core-localization\/","name":"ASP.NET Core localization: How to translate a Razor Pages app with .resx files - POEditor Blog","isPartOf":{"@id":"https:\/\/poeditor.com\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/poeditor.com\/blog\/asp-net-core-localization\/#primaryimage"},"image":{"@id":"https:\/\/poeditor.com\/blog\/asp-net-core-localization\/#primaryimage"},"thumbnailUrl":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/ASP.NET-Core-localization-1024x489.png","datePublished":"2026-10-26T06:15:00+00:00","dateModified":"2026-10-26T08:54:32+00:00","description":"Learn how ASP.NET Core localization works and how to build multilingual web apps, configure cultures, manage resource files, and translate.","breadcrumb":{"@id":"https:\/\/poeditor.com\/blog\/asp-net-core-localization\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/poeditor.com\/blog\/asp-net-core-localization\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/poeditor.com\/blog\/asp-net-core-localization\/#primaryimage","url":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/ASP.NET-Core-localization.png","contentUrl":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/10\/ASP.NET-Core-localization.png","width":1920,"height":917,"caption":"ASP.NET Core localization"},{"@type":"BreadcrumbList","@id":"https:\/\/poeditor.com\/blog\/asp-net-core-localization\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/poeditor.com\/blog\/"},{"@type":"ListItem","position":2,"name":"ASP.NET Core localization: How to translate a Razor Pages app with .resx files"}]},{"@type":"WebSite","@id":"https:\/\/poeditor.com\/blog\/#website","url":"https:\/\/poeditor.com\/blog\/","name":"POEditor Blog","description":"All about translation and localization management","publisher":{"@id":"https:\/\/poeditor.com\/blog\/#organization"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/poeditor.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":"Organization","@id":"https:\/\/poeditor.com\/blog\/#organization","name":"POEditor","url":"https:\/\/poeditor.com\/blog\/","logo":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/poeditor.com\/blog\/#\/schema\/logo\/image\/","url":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2019\/11\/logo_head_512_transparent.png","contentUrl":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2019\/11\/logo_head_512_transparent.png","width":512,"height":512,"caption":"POEditor"},"image":{"@id":"https:\/\/poeditor.com\/blog\/#\/schema\/logo\/image\/"},"sameAs":["https:\/\/www.facebook.com\/POEditor","https:\/\/x.com\/poeditor","https:\/\/www.linkedin.com\/company\/poeditor\/","https:\/\/www.youtube.com\/channel\/UCXAk1u8N49VRMAqNneENCFA"]},{"@type":"Person","@id":"https:\/\/poeditor.com\/blog\/#\/schema\/person\/db77eb2d09539eaac83dcd8f2af06b17","name":"POEditor","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/secure.gravatar.com\/avatar\/95bca2c465fe6134f210cb2f2fbed4f71bf37833fb285624320e808c865695c1?s=96&d=mm&r=g","url":"https:\/\/secure.gravatar.com\/avatar\/95bca2c465fe6134f210cb2f2fbed4f71bf37833fb285624320e808c865695c1?s=96&d=mm&r=g","contentUrl":"https:\/\/secure.gravatar.com\/avatar\/95bca2c465fe6134f210cb2f2fbed4f71bf37833fb285624320e808c865695c1?s=96&d=mm&r=g","caption":"POEditor"},"url":"https:\/\/poeditor.com\/blog\/author\/poeditor\/"}]}},"_links":{"self":[{"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/posts\/8528","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/users\/6"}],"replies":[{"embeddable":true,"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/comments?post=8528"}],"version-history":[{"count":6,"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/posts\/8528\/revisions"}],"predecessor-version":[{"id":8564,"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/posts\/8528\/revisions\/8564"}],"wp:attachment":[{"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/media?parent=8528"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/categories?post=8528"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/tags?post=8528"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}