Localization in ASP.NET Core Web API: Multi-Language Support
Add multi-language support to ASP.NET Core Web APIs. Covers IStringLocalizer, resource files (.resx), culture middleware, controller-based vs shared resources, Accept-Language header, and data annotation localization.
What is Localization?
Localization (l10n) adapts your application to a specific culture — language, date formats, number formats, and currency. An API that serves users across multiple countries should return messages and data in the user's preferred language.
GET /api/orders/123
Accept-Language: ar
Response:
{ "message": "تم إنشاء الطلب بنجاح" } ← Arabic
GET /api/orders/123
Accept-Language: en-US
Response:
{ "message": "Order created successfully" } ← EnglishSetup
// Program.cs
builder.Services.AddLocalization(options =>
options.ResourcesPath = "Resources");
builder.Services.Configure<RequestLocalizationOptions>(options =>
{
var supportedCultures = new[] { "en-US", "ar", "fr", "de", "es" };
options.SetDefaultCulture("en-US")
.AddSupportedCultures(supportedCultures)
.AddSupportedUICultures(supportedCultures);
// How to determine the culture for each request
options.RequestCultureProviders = new List<IRequestCultureProvider>
{
new AcceptLanguageHeaderRequestCultureProvider(), // from Accept-Language header
new QueryStringRequestCultureProvider(), // ?culture=ar
new CookieRequestCultureProvider() // from cookie
};
});
// Add middleware — must be BEFORE UseRouting and UseAuthorization
app.UseRequestLocalization();Resource Files
Resource files (.resx) store translations as key-value pairs.
Resources/
├── Controllers/
│ ├── OrdersController.en-US.resx ← English (default)
│ ├── OrdersController.ar.resx ← Arabic
│ └── OrdersController.fr.resx ← French
└── SharedResource.en-US.resx
SharedResource.ar.resxOrdersController.en-US.resx: | Key | Value | |---|---| | OrderCreated | Order created successfully | | OrderNotFound | Order was not found | | InsufficientStock | Insufficient stock for product |
OrdersController.ar.resx: | Key | Value | |---|---| | OrderCreated | تم إنشاء الطلب بنجاح | | OrderNotFound | الطلب غير موجود | | InsufficientStock | المخزون غير كافٍ للمنتج |
Create .resx files in Visual Studio: right-click Resources folder → Add → New Item → Resources File.
Controller-Based Localisation
One resource file per controller — best for large APIs with distinct domains.
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
private readonly IStringLocalizer<OrdersController> _localizer;
private readonly IOrderRepository _orders;
public OrdersController(
IStringLocalizer<OrdersController> localizer,
IOrderRepository orders)
{
_localizer = localizer;
_orders = orders;
}
[HttpPost]
public async Task<IActionResult> Create(
[FromBody] CreateOrderRequest request,
CancellationToken ct)
{
var order = await _orders.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = order.Id }, new
{
order.Id,
Message = _localizer["OrderCreated"].Value
});
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
{
var order = await _orders.GetByIdAsync(id, ct);
if (order is null)
return NotFound(new { Message = string.Format(_localizer["OrderNotFound"], id) });
return Ok(order);
}
}Shared Resources
When many controllers share the same translations, use a shared resource.
// Models/SharedResource.cs — marker class (can be empty)
public class SharedResource { }Resources/
├── SharedResource.en-US.resx ← shared translations
└── SharedResource.ar.resxSharedResource.en-US.resx: | Key | Value | |---|---| | InvalidEmail | Invalid email address | | Required | is required | | ServerError | An unexpected error occurred |
// Inject shared resource in any controller
public class AuthController : ControllerBase
{
private readonly IStringLocalizer<SharedResource> _sharedLocalizer;
public AuthController(IStringLocalizer<SharedResource> sharedLocalizer)
=> _sharedLocalizer = sharedLocalizer;
[HttpPost("login")]
public IActionResult Login([FromBody] LoginRequest request)
{
if (string.IsNullOrEmpty(request.Email))
return BadRequest(_sharedLocalizer["Required", "Email"].Value);
// ...
}
}Localising Validation Messages
// DataAnnotations with localised messages
public class CreateOrderRequest
{
[Required(ErrorMessage = "CustomerIdRequired")]
public string CustomerId { get; set; } = "";
[Range(1, 1000, ErrorMessage = "QuantityRange")]
public int Quantity { get; set; }
}
// Program.cs — wire up localisation for DataAnnotations
builder.Services.AddControllers()
.AddDataAnnotationsLocalization(options =>
{
options.DataAnnotationLocalizerProvider = (type, factory) =>
factory.Create(typeof(SharedResource));
});SharedResource.ar.resx: | Key | Value | |---|---| | CustomerIdRequired | معرّف العميل مطلوب | | QuantityRange | يجب أن تكون الكمية بين 1 و 1000 |
Localising Exception Messages
// Custom exception with localised message
public class BusinessException : Exception
{
public string ResourceKey { get; }
public object[] Args { get; }
public BusinessException(string resourceKey, params object[] args)
: base(resourceKey)
{
ResourceKey = resourceKey;
Args = args;
}
}
// Global exception middleware with localisation
public class LocalisedExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly IStringLocalizer<SharedResource> _localizer;
public LocalisedExceptionMiddleware(
RequestDelegate next,
IStringLocalizer<SharedResource> localizer)
{
_next = next;
_localizer = localizer;
}
public async Task InvokeAsync(HttpContext ctx)
{
try
{
await _next(ctx);
}
catch (BusinessException ex)
{
var message = ex.Args.Length > 0
? string.Format(_localizer[ex.ResourceKey], ex.Args)
: _localizer[ex.ResourceKey].Value;
ctx.Response.StatusCode = 422;
await ctx.Response.WriteAsJsonAsync(new { message });
}
}
}Testing with Postman
Add the Accept-Language header to simulate different locales:
GET /api/orders
Accept-Language: ar
GET /api/orders
Accept-Language: fr-FR
GET /api/orders
Accept-Language: en-US, en;q=0.9, ar;q=0.8 ← quality values for priorityCurrency and Date Localisation
// Localise numbers, dates, and currencies in responses
public class LocalisedOrderDto
{
public Guid Id { get; set; }
public string Total { get; set; } = "";
public string CreatedAt { get; set; } = "";
public static LocalisedOrderDto FromOrder(Order order)
{
var culture = CultureInfo.CurrentCulture;
return new LocalisedOrderDto
{
Id = order.Id,
Total = order.Total.ToString("C", culture), // £100.00 or $100.00 or 100,00 €
CreatedAt = order.CreatedAt.ToString("D", culture) // "Wednesday, 04 June 2026" or "الأربعاء, 4 يونيو 2026"
};
}
}Interview Questions
Q: What is the difference between localisation and globalisation in .NET?
Globalisation (globalization) makes code aware of cultural differences — date formats, number formats, currency. Localisation applies specific translations and adaptations for a particular culture. IStringLocalizer handles localisation; CultureInfo handles globalization formatting.
Q: How does ASP.NET Core determine the culture for a request?
Through RequestCultureProviders checked in order: AcceptLanguageHeaderRequestCultureProvider reads the Accept-Language header, QueryStringRequestCultureProvider reads ?culture=, CookieRequestCultureProvider reads a cookie. The first matching provider wins.
Q: What is a shared resource and when would you use it?
A single .resx file accessible across all controllers via IStringLocalizer<SharedResource>. Use it for strings that appear in many places — validation messages, common errors, standard responses. Use controller-specific resources for domain-specific strings.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.