Localization in MVC and Razor Pages: IStringLocalizer, IViewLocalizer, Request Culture and the Turkish "i" Pitfall
UseRequestLocalization picks a culture per request from the query string, cookie, Accept-Language or the URL; .resx files, IViewLocalizer and DataAnnotations messages are translated with it. We make Bookstore's catalog TR/EN and show the tr vs tr-TR gap, 25,5 becoming 255 in the query string, the "C" format and the Turkish i pitfall with real output.
Version note: The code in this post was built and run with .NET 10 (SDK 10.0.402); culture data comes from the ICU (International Components for Unicode) library on macOS.
Guess First
Bookstore's Turkish catalog lives at /tr/catalog, and the page takes a MaxPrice filter that shows books under a given amount. A Turkish user types ?maxPrice=25,5 into the address bar out of habit. The catalog has two books under 25.50. How many books does the user see? The same user types 42,50 into the price field in the admin panel and saves; an editor using the same form in the English interface also types 42,50. Do two different numbers end up in the database? The answers are in the middle of the post, with real output.
Where Does the Culture Come From?
.NET has two separate cultures. CultureInfo.CurrentCulture determines how numbers, dates and currencies are formatted, how strings are compared, and how upper/lower case conversion works. CultureInfo.CurrentUICulture chooses which .resx file text is read from. When people say localization they usually think of the second one, but most bugs come from the first.
If you set neither, the server's operating system decides. I'm writing this series' examples on a Turkish macOS. When I first ran the list from the Razor Pages post, the prices came out like this:
<td>The Pragmatic Path</td>
<td>Ada Lane</td>
<td>42,50</td>
There was no culture setting in the code; ToString("0.00") used the machine's language. The same app writes 42.50 in an English Linux container. Everything looks right locally and different on the server. That's why I captured the later output in this series with LC_ALL=en_US.UTF-8. In this post we stop leaving culture to the operating system and pick it ourselves for every request.
Setup: Services and Middleware
builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
builder.Services.AddControllersWithViews()
.AddViewLocalization()
.AddDataAnnotationsLocalization(options =>
options.DataAnnotationLocalizerProvider = (_, factory) => factory.Create(typeof(SharedResource)));
builder.Services.Configure<RequestLocalizationOptions>(options =>
{
string[] cultures = ["en-US", "tr-TR"];
options.SetDefaultCulture("en-US")
.AddSupportedCultures(cultures)
.AddSupportedUICultures(cultures);
options.ApplyCurrentCultureToResponseHeaders = true;
});
The pipeline gets a single line, but its position matters:
app.UseRouting();
app.UseRequestLocalization();
AddLocalization registers IStringLocalizer<T> and the factory that reads .resx files. AddViewLocalization brings IViewLocalizer to views, and AddDataAnnotationsLocalization routes validation messages through the localizer. On every request UseRequestLocalization tries a list of providers in order and makes the culture of the first provider that answers the request's CurrentCulture and CurrentUICulture. The setting stays bound to the request across the async flow; it doesn't leak into other requests. ApplyCurrentCultureToResponseHeaders adds the selected culture to the response as a Content-Language header; in the tests below we read which provider won from that header.
Request Culture Providers
The default order is: query string (?culture=tr-TR), cookie (.AspNetCore.Culture), then the Accept-Language header sent by the browser. If none of them gives a result, the culture you passed to SetDefaultCulture is used. Real requests:
Accept-Language: tr-TR,tr;q=0.9,en;q=0.8 -> Content-Language: tr-TR
Accept-Language: de-DE,tr-TR;q=0.5 -> Content-Language: tr-TR
Accept-Language: tr -> Content-Language: en-US
?culture=tr-TR -> Content-Language: tr-TR
Cookie tr-TR + Accept-Language: en-US -> Content-Language: tr-TR
Look at the third line. The supported list has tr-TR, the client sends only tr, and the result is English. When the middleware can't find a culture it walks up to its parent (tr-TR → tr), but it doesn't walk down from tr to tr-TR. Adding the neutral culture to the supported list closes that gap, but with tr currency formatting comes out as ¤39,99; a neutral culture has no currency. That's also one reason for the "don't leave currency to the culture" rule we'll discuss below.
A small Razor Page that writes a cookie is enough to let users pick a language:
public class CultureModel : PageModel
{
public IActionResult OnPost(string culture, string? returnUrl)
{
Response.Cookies.Append(
CookieRequestCultureProvider.DefaultCookieName,
CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
new CookieOptions { Expires = DateTimeOffset.UtcNow.AddYears(1), IsEssential = true });
return LocalRedirect(returnUrl ?? "/");
}
}
HTTP/1.1 302 Found
Location: /Manage/Books
Set-Cookie: .AspNetCore.Culture=c%3Dtr-TR%7Cuic%3Dtr-TR; expires=Mon, 08 Nov 2027 02:13:22 GMT; path=/
The cookie value has the form c=tr-TR|uic=tr-TR: c is the formatting culture, uic the UI (User Interface) culture. LocalRedirect only redirects to addresses inside the app; since returnUrl is a query string parameter, this is open redirect protection.
Culture in the URL: /tr/catalog
For search engines and shared links, putting the culture in the URL (Uniform Resource Locator) is the most robust option. My first attempt was adding the documentation's RouteDataRequestCultureProvider to the front of the list. The /tr/catalog page opened without errors, but the heading said Catalog (en-US) and the header said Content-Language: en-US. The reason is the same as the tr line above: the route value is tr, the supported culture is tr-TR. When the provider's result isn't supported, the middleware silently moves to the next provider. The fix is a custom provider that translates the route value into a full culture name:
// /tr/catalog -> tr-TR; route values only exist after UseRouting
options.AddInitialRequestCultureProvider(new CustomRequestCultureProvider(context =>
{
ProviderCultureResult? result = context.GetRouteValue("culture") switch
{
"tr" => new ProviderCultureResult("tr-TR"),
"en" => new ProviderCultureResult("en-US"),
_ => null
};
return Task.FromResult(result);
}));
This provider is why we put UseRequestLocalization after UseRouting: route values only exist in HttpContext after routing has matched. The page itself restricts the culture with its route template:
@page "/{culture:regex(^(en|tr)$)}/catalog"
@model Bookstore.Web.Pages.Catalog.IndexModel
@inject IViewLocalizer Localizer
@{
ViewData["Title"] = Localizer["Catalog"];
}
<h1>@Localizer["Catalog"] (@Model.CultureName)</h1>
<p>@Localizer["{0} books", Model.Books.Count] / max: @Model.MaxPrice</p>
<table>
<tr><th>@Localizer["Title"]</th><th>@Localizer["Price"]</th><th>@Localizer["Published"]</th></tr>
@foreach (Book book in Model.Books)
{
<tr>
<td>@book.Title</td>
<td>@book.Price.ToString("N2") | @book.Price.ToString("C")</td>
<td>@book.PublishedOn.ToString("d") | @book.PublishedOn.ToString("D")</td>
</tr>
}
</table>
.resx Files and IViewLocalizer
IViewLocalizer finds the resource file from the view's path. For Pages/Catalog/Index.cshtml with ResourcesPath = "Resources", the file it looks for is Resources/Pages/Catalog/Index.tr.resx:
<data name="Catalog" xml:space="preserve">
<value>Katalog</value>
</data>
<data name="{0} books" xml:space="preserve">
<value>{0} kitap</value>
</data>
There's no .resx file for English. When a key isn't found the localizer returns the key itself; since we wrote the English text as the key, the English page needs no file at all. The real output for both languages:
<h1>Catalog (en-US)</h1>
<p>4 books / max: </p>
<tr><th>Title</th><th>Price</th><th>Published</th></tr>
<td>Clean Lines</td>
<td>39.99 | $39.99</td>
<td>1/20/2023 | Friday, January 20, 2023</td>
<h1>Katalog (tr-TR)</h1>
<p>4 kitap / max: </p>
<tr><th>Başlık</th><th>Fiyat</th><th>Yayın tarihi</th></tr>
<td>Clean Lines</td>
<td>39,99 | ₺39,99</td>
<td>20.01.2023 | 20 Ocak 2023 Cuma</td>
₺ looks odd. Other rows are similar:
<td>5.09.2022 | 5 Eylül 2022 Pazartesi</td>
<td>1.10.2019 | 1 Ekim 2019 Salı</td>
Razor HTML (HyperText Markup Language) encodes every value printed with @, and the default encoder only leaves the Basic Latin range as is. "ü", "ı", "ş" and "₺" turn into numeric character references. Browsers display them correctly, so it's not a security or display problem. But the HTML source becomes unreadable, page size grows, and tests that search the HTML for text break. In an app with Turkish content, widening the encoder's allowed ranges is a common setting:
// don't turn "ü" into "ü" in Razor output
builder.Services.Configure<WebEncoderOptions>(options =>
options.TextEncoderSettings = new TextEncoderSettings(UnicodeRanges.All));
After this setting the same rows come out as ₺39,99 and 5 Eylül 2022 Pazartesi. <, >, & and quotes are still encoded; you lose nothing from XSS (Cross-Site Scripting) protection. We saw Razor's encoding behavior in the Razor syntax post of this series. IViewLocalizer has one difference: it doesn't encode the resource string itself, only parameters like {0}. You can put HTML in a translation file, but that means a file handed to translators can now print HTML straight into your page. Microsoft's documentation also says "only localize text, not HTML".
DataAnnotations Messages and Display Names
For the form's validation messages we use a single shared resource: the SharedResource marker class and Resources/SharedResource.tr.resx. The most overlooked part of the rule: the localizer only uses the ErrorMessage you wrote as a key. If you leave [Required] without a message, the framework's default English message is never looked up in the resource.
[Display(Name = "Title")]
[Required(ErrorMessage = "The {0} field is required."), StringLength(120)]
public string Title { get; set; } = "";
[Required, RegularExpression(@"^\d{13}$", ErrorMessage = "ISBN must be 13 digits.")]
public string Isbn { get; set; } = "";
[Display(Name = "Price")]
[Range(0.01, 10000, ErrorMessage = "The field {0} must be between {1} and {2}.")]
public decimal Price { get; set; }
Submitting an empty form under Turkish culture:
<label for="Input_Title">Başlık</label>
<span class="field-validation-error" data-valmsg-for="Input.Title" data-valmsg-replace="true">Başlık alanı zorunludur.</span>
<label for="Input_Isbn">Isbn</label>
<span class="field-validation-error" data-valmsg-for="Input.Isbn" data-valmsg-replace="true">ISBN 13 haneden oluşmalıdır.</span>
<label for="Input_Price">Fiyat</label>
<span class="field-validation-error" data-valmsg-for="Input.Price" data-valmsg-replace="true">Fiyat alanı 0,01 ile 10000 arasında olmalıdır.</span>
<label for="Input_Stock">Stock</label>
On the fields where we added [Display(Name = "Title")], both the label and the {0} in the message are Turkish. Stock has no Display attribute, so its label stays as the property name. In the same page's HTML, the Isbn input's data-val-required attribute still says The Isbn field is required., because that [Required] has no message. data-val-number="The field Price must be a number." also stays English; that message doesn't come from DataAnnotations but from model binding's ModelBindingMessageProvider, and it has to be translated separately through MvcOptions. We discussed its client-side side in the validation post.
Numbers, Dates and Currency
Look again at the ToString("C") column in the catalog output: the same book is $39.99 on the English page and ₺39,99 on the Turkish one. The book's price didn't change, only the currency symbol did. In other words, it's wrong. The "C" format doesn't know which currency the amount is in; it takes the symbol from the current culture. If prices are stored in a single currency, format the number with N2 and add the currency from the data or from a fixed code. For dates, leaving it to the culture is right: d and D give the order readers expect in each language.
Now the opening questions. The real result for maxPrice in the query string:
/tr/catalog?maxPrice=25,5 -> <p>4 kitap / max: 255</p>
/tr/catalog?maxPrice=25.5 -> <p>2 kitap / max: 25,5</p>
On the Turkish page, with Turkish culture selected, 25,5 was bound as 255 and all four books were listed. The reason is a deliberate decision in ASP.NET Core: route and query string values are bound with InvariantCulture, so that culture-independent URLs can be produced. In the invariant culture the comma is a thousands separator; 25,5 doesn't fail, it becomes 255. Form values, on the other hand, are bound with the current culture. I sent four separate POSTs to the same edit form:
tr-TR Input.Price=42,50 -> 42.50
tr-TR Input.Price=42.50 -> 4250.00
en-US Input.Price=42,50 -> 4250.00
en-US Input.Price=42.50 -> 42.50
Two users typed the same 42,50 and two different numbers went into the database; neither got a validation error, because 4250 is also within [Range(0.01, 10000)]. The server side is consistent with itself: in the Turkish UI the input renders with value="42,50" and is read back with the same culture. The problem appears when the culture changes or someone types the other separator out of habit. So for critical fields like money it's worth considering an explicit separator check, a sensible upper bound and, if needed, a type="number" input (the browser always sends the value with a dot).
The Turkish "i" Pitfall
Turkish is one of the few languages whose case mapping differs from English: the upper case of "i" is "İ", the lower case of "I" is "ı". The moment you turn on request localization, every request from Turkish users runs under tr-TR, and every culture-sensitive string operation follows this rule:
CultureInfo.CurrentCulture = new CultureInfo("tr-TR");
string role = "admin";
Console.WriteLine($"ToUpper: {role.ToUpper()}");
Console.WriteLine($"== \"ADMIN\": {role.ToUpper() == "ADMIN"}");
Console.WriteLine($"ToUpperInvariant: {role.ToUpperInvariant() == "ADMIN"}");
Console.WriteLine($"\"ISBN\".ToLower(): {"ISBN".ToLower()}");
Console.WriteLine($"CurrentCultureIgnore: {string.Equals("FILE", "file", StringComparison.CurrentCultureIgnoreCase)}");
Console.WriteLine($"OrdinalIgnoreCase: {string.Equals("FILE", "file", StringComparison.OrdinalIgnoreCase)}");
ToUpper: ADMİN
== "ADMIN": False
ToUpperInvariant: True
"ISBN".ToLower(): ısbn
CurrentCultureIgnore: False
OrdinalIgnoreCase: True
A line like if (user.Role.ToUpper() == "ADMIN") works for months and breaks the day a language selector is added, only for administrators who choose Turkish. No error reaches the log; the user just sees that they're unauthorized. The same problem lurks in cache keys built with "ISBN".ToLower(), file extension checks (".PDF".ToLower()) and header name comparisons. The rule is simple: for strings a machine reads (role names, keys, extensions, protocol values), use Ordinal/OrdinalIgnoreCase and ToUpperInvariant; culture is only for text a human reads.
Sorting is the opposite; for a list shown to people you want culture-aware comparison:
tr-TR order: Canberra, Çanakkale, Istanbul, Izmir, Ödemiş, Zagreb
ordinal order: Canberra, Istanbul, Izmir, Zagreb, Çanakkale, Ödemiş
Ordinal sorting looks at Unicode code points, so "Çanakkale" and "Ödemiş" drop to the end of the list. Sorting on the database side depends on the collation; as we saw in the Entity Framework Core (EF Core) series, that's a separate decision.
Production Notes
Make sure the container has ICU. .NET's chiseled container images don't contain ICU and are designed to run in globalization-invariant mode; there are -extra variants for apps that need ICU and tzdata. When I tried to create tr-TR in invariant mode, the app didn't even start:
Unhandled exception. System.Globalization.CultureNotFoundException: Only the invariant culture is supported in globalization-invariant mode. See https://aka.ms/GlobalizationInvariantMode for more information. (Parameter 'name')
tr-TR is an invalid culture identifier.
When caching localized responses, include the culture in the key. If the same URL returns content in two languages depending on a cookie, an output cache or reverse proxy that doesn't know the culture serves the first language to everyone. Carrying the culture in the URL solves this at the root; with cookie-based selection the cache has to vary by culture (a topic for the upcoming Caching series).
The culture cookie is technically a preference cookie. If the app uses cookie consent and you don't set IsEssential = true, a user who hasn't consented won't have their language choice saved and won't understand why the site keeps switching back to English.
Decide on translation keys. We used the English sentence as the key. It's quick to start and needs no file for English, but the moment you fix a comma in the English text the key changes and the Turkish translation silently disappears; English text shows up on the page. In growing apps, semantic keys like Catalog.Title plus a .resx file for English too make maintenance easier. This choice matters more as the team and the number of translators grow.
Common Pitfalls
Setting CultureInfo.DefaultThreadCurrentCulture in Program.cs. It changes the default for all new threads in the process; it isn't per request. Let UseRequestLocalization choose the culture, and have non-request code such as background services set the culture explicitly.
Expecting tr to match when only tr-TR is supported. Whether it comes from a route value, the query string or Accept-Language, tr isn't mapped down to tr-TR, and the user silently falls back to the default language.
Assuming default validation messages get translated. Attributes without a message and model binding messages never reach the localizer.
Leaving the currency to the "C" format. When the culture changes, the price looks as if its currency changed.
Assuming a decimal in the query string is read with the user's culture. Route and query values are bound with the invariant culture; 25,5 becomes 255.
A Small Experiment
★☆☆ Delete the {0} books key from Resources/Pages/Catalog/Index.tr.resx and open /tr/catalog. What do you see on the page? Write a helper that uses the LocalizedString.ResourceNotFound property returned by IStringLocalizer to catch untranslated text in the log.
★★☆ Give the [Required] on Isbn an ErrorMessage and add its translation to SharedResource.tr.resx. Then translate "The field {0} must be a number." into Turkish as well with MvcOptions.ModelBindingMessageProvider.SetValueMustBeANumberAccessor.
★★★ Prevent the /tr/catalog?maxPrice=25,5 request from turning into 255: write a culture-aware IValueProviderFactory for the query string, or take MaxPrice as a string and read it explicitly with decimal.TryParse(..., NumberStyles.Number, CultureInfo.CurrentCulture, ...). Discuss which one causes fewer surprises when URLs are shared: what does a link copied by a Turkish user show to an English user?
In this post we brought our pages to two languages and two cultures. Every interaction still means a full page load: change the filter and the whole page comes back. In the next post we add interactivity to Razor Pages with htmx without writing an SPA (Single-Page Application): live search, infinite scroll, inline editing, and carrying the antiforgery token into htmx requests.