Setting Up Localization in .NET Aspire: A Complete Guide
Hey guys! I've been playing with .NET Aspire lately, and one of the things I needed to figure out was how to properly set up localization. If you're building an app meant for users across different regions and languages, this post is for you. I'll walk you through setting up a robust localization system in a .NET Aspire project that handles both query parameters and Accept-Language headers. The coolest part? We'll use Redis for caching our localized strings to keep things snappy. What We're Building For this tutorial, I've created a sample project called "GlobeShop" - an e-commerce API that needs to support multiple languages. By the end, you'll have: Localization powered by Redis caching Support for both URL query parameters and browser language settings A clean approach that keeps localization logic out of your controllers Let's get started! Project Setup First, let's look at our .NET Aspire project structure: GlobeShop/ ├── GlobeShop.ApiService/ ├── GlobeShop.AppHost/ ├── GlobeShop.ServiceDefaults/ │ └── localization/ │ ├── en.json │ └── ar.json └── GlobeShop.Web/ The magic happens mostly in the ServiceDefaults project, which contains our shared localization code. Step 1: Create Localization JSON Files First, let's create JSON files for each language we want to support. In our GlobeShop.ServiceDefaults/localization/ folder: en.json: { "culture": "en", "texts": { "Welcome": "Welcome to GlobeShop!", "ProductsTitle": "Our Products", "AddToCart": "Add to Cart" } } ar.json: { "culture": "ar", "texts": { "Welcome": "مرحبًا بكم في جلوب شوب!", "ProductsTitle": "منتجاتنا", "AddToCart": "أضف إلى السلة" } } Step 2: Create the LocalizationData Model In our ServiceDefaults project, we need a class to represent our localization data: using System.Text.Json.Serialization; public class LocalizationData { [JsonPropertyName("culture")] public string Culture { get; set; } = string.Empty; [JsonPropertyName("texts")] public Dictionary Texts { get; set; } = new(); } Notice the JsonPropertyName attributes - these are crucial! They map the lowercase property names in our JSON to the C# properties. Without these, the deserializer won't correctly match the properties. Step 3: Create a Redis-Backed String Localizer Next, we'll create a custom string localizer that pulls localized strings from Redis: public class CacheStringLocalizer : IStringLocalizer { private readonly IDistributedCache _cache; private readonly string _culture; private readonly ILogger _logger; public CacheStringLocalizer(IDistributedCache cache, ILogger logger) { _cache = cache; _logger = logger; _culture = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName; _logger.LogDebug($"CacheStringLocalizer initialized with culture: {_culture}"); } public LocalizedString this[string name] { get { var cacheKey = $"localization:{_culture}:{name}"; var value = _cache.GetString(cacheKey); if (value == null) { // Try fallback to default culture if not found if (_culture != "en") { var fallbackKey = $"localization:en:{name}"; value = _cache.GetString(fallbackKey); } if (value == null) { return new LocalizedString(name, name, resourceNotFound: true); } } return new LocalizedString(name, value, resourceNotFound: false); } } public LocalizedString this[string name, params object[] arguments] { get { var value = this[name]; return value.ResourceNotFound ? value : new LocalizedString(name, string.Format(value.Value, arguments), false); } } public IEnumerable GetAllStrings(bool includeParentCultures) { // Not implementing for this example return Array.Empty(); } } Step 4: Create a Factory for our String Localizer We need a factory to create instances of our localizer: public class CacheStringLocalizerFactory : IStringLocalizerFactory { private readonly IDistributedCache _cache; private readonly ILoggerFactory _loggerFactory; public CacheStringLocalizerFactory(IDistributedCache cache, ILoggerFactory loggerFactory) { _cache = cache; _loggerFactory = loggerFactory; } public IStringLocalizer Create(Type resourceSource) { var logger = _loggerFactory.CreateLogger(); return new CacheStringLocalizer(_cache, logger); } public IStringLocalizer Create(string baseName, string location) { var logger = _loggerFactory.CreateLogger(); return new CacheStringLocalizer

Hey guys! I've been playing with .NET Aspire lately, and one of the things I needed to figure out was how to properly set up localization. If you're building an app meant for users across different regions and languages, this post is for you.
I'll walk you through setting up a robust localization system in a .NET Aspire project that handles both query parameters and Accept-Language headers. The coolest part? We'll use Redis for caching our localized strings to keep things snappy.
What We're Building
For this tutorial, I've created a sample project called "GlobeShop" - an e-commerce API that needs to support multiple languages. By the end, you'll have:
- Localization powered by Redis caching
- Support for both URL query parameters and browser language settings
- A clean approach that keeps localization logic out of your controllers
Let's get started!
Project Setup
First, let's look at our .NET Aspire project structure:
GlobeShop/
├── GlobeShop.ApiService/
├── GlobeShop.AppHost/
├── GlobeShop.ServiceDefaults/
│ └── localization/
│ ├── en.json
│ └── ar.json
└── GlobeShop.Web/
The magic happens mostly in the ServiceDefaults
project, which contains our shared localization code.
Step 1: Create Localization JSON Files
First, let's create JSON files for each language we want to support. In our GlobeShop.ServiceDefaults/localization/
folder:
en.json:
{
"culture": "en",
"texts": {
"Welcome": "Welcome to GlobeShop!",
"ProductsTitle": "Our Products",
"AddToCart": "Add to Cart"
}
}
ar.json:
{
"culture": "ar",
"texts": {
"Welcome": "مرحبًا بكم في جلوب شوب!",
"ProductsTitle": "منتجاتنا",
"AddToCart": "أضف إلى السلة"
}
}
Step 2: Create the LocalizationData Model
In our ServiceDefaults
project, we need a class to represent our localization data:
using System.Text.Json.Serialization;
public class LocalizationData
{
[JsonPropertyName("culture")]
public string Culture { get; set; } = string.Empty;
[JsonPropertyName("texts")]
public Dictionary<string, string> Texts { get; set; } = new();
}
Notice the JsonPropertyName
attributes - these are crucial! They map the lowercase property names in our JSON to the C# properties. Without these, the deserializer won't correctly match the properties.
Step 3: Create a Redis-Backed String Localizer
Next, we'll create a custom string localizer that pulls localized strings from Redis:
public class CacheStringLocalizer : IStringLocalizer
{
private readonly IDistributedCache _cache;
private readonly string _culture;
private readonly ILogger<CacheStringLocalizer> _logger;
public CacheStringLocalizer(IDistributedCache cache, ILogger<CacheStringLocalizer> logger)
{
_cache = cache;
_logger = logger;
_culture = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
_logger.LogDebug($"CacheStringLocalizer initialized with culture: {_culture}");
}
public LocalizedString this[string name]
{
get
{
var cacheKey = $"localization:{_culture}:{name}";
var value = _cache.GetString(cacheKey);
if (value == null)
{
// Try fallback to default culture if not found
if (_culture != "en")
{
var fallbackKey = $"localization:en:{name}";
value = _cache.GetString(fallbackKey);
}
if (value == null)
{
return new LocalizedString(name, name, resourceNotFound: true);
}
}
return new LocalizedString(name, value, resourceNotFound: false);
}
}
public LocalizedString this[string name, params object[] arguments]
{
get
{
var value = this[name];
return value.ResourceNotFound
? value
: new LocalizedString(name, string.Format(value.Value, arguments), false);
}
}
public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
{
// Not implementing for this example
return Array.Empty<LocalizedString>();
}
}
Step 4: Create a Factory for our String Localizer
We need a factory to create instances of our localizer:
public class CacheStringLocalizerFactory : IStringLocalizerFactory
{
private readonly IDistributedCache _cache;
private readonly ILoggerFactory _loggerFactory;
public CacheStringLocalizerFactory(IDistributedCache cache, ILoggerFactory loggerFactory)
{
_cache = cache;
_loggerFactory = loggerFactory;
}
public IStringLocalizer Create(Type resourceSource)
{
var logger = _loggerFactory.CreateLogger<CacheStringLocalizer>();
return new CacheStringLocalizer(_cache, logger);
}
public IStringLocalizer Create(string baseName, string location)
{
var logger = _loggerFactory.CreateLogger<CacheStringLocalizer>();
return new CacheStringLocalizer(_cache, logger);
}
}
Step 5: Create a Service to Load Localization Data
We need a background service that loads our localization files into Redis at startup:
public class LocalizationLoader : IHostedService
{
private readonly IDistributedCache _cache;
private readonly ILogger<LocalizationLoader> _logger;
public LocalizationLoader(IDistributedCache cache, ILogger<LocalizationLoader> logger)
{
_cache = cache;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Loading localization resources...");
var assembly = Assembly.GetExecutingAssembly();
var resourceNames = assembly.GetManifestResourceNames()
.Where(name => name.EndsWith(".json") && name.Contains("localization"));
foreach (var resourceName in resourceNames)
{
_logger.LogInformation($"Processing resource: {resourceName}");
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null) continue;
using var reader = new StreamReader(stream);
var json = await reader.ReadToEndAsync();
// Use case-insensitive property matching
var options = new JsonSerializerOptions {
PropertyNameCaseInsensitive = true
};
try
{
var data = JsonSerializer.Deserialize<LocalizationData>(json, options);
if (string.IsNullOrWhiteSpace(data?.Culture)) continue;
_logger.LogInformation($"Found culture: {data.Culture} with {data.Texts.Count} entries");
// Store each text entry in Redis
foreach (var (key, value) in data.Texts)
{
var cacheKey = $"localization:{data.Culture}:{key}";
await _cache.SetStringAsync(cacheKey, value, cancellationToken);
}
}
catch (Exception ex)
{
_logger.LogError($"Error processing {resourceName}: {ex.Message}");
}
}
_logger.LogInformation("Localization resources loaded.");
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
Step 6: Add Extension Methods for Easy Configuration
Let's add some extension methods to our ServiceDefaults
project to make configuration easy:
public static class Extensions
{
// Existing extensions...
public static IHostApplicationBuilder AddLocalizationDefaults<TBuilder>(this TBuilder builder)
where TBuilder : IHostApplicationBuilder
{
// Add localization services
builder.Services.AddLocalization();
// Configure supported cultures
builder.Services.Configure<RequestLocalizationOptions>(options =>
{
var supportedCultures = new[] { "en", "ar", "fr" };
var cultures = supportedCultures.Select(c => new CultureInfo(c)).ToList();
options.DefaultRequestCulture = new RequestCulture("en");
options.SupportedCultures = cultures;
options.SupportedUICultures = cultures;
// Culture providers in priority order:
// 1. QueryStringRequestCultureProvider (default query params: culture, ui-culture)
// 2. CookieRequestCultureProvider
// 3. AcceptLanguageHeaderRequestCultureProvider
});
// Register our custom services
builder.Services.AddHostedService<LocalizationLoader>();
builder.Services.AddSingleton<IStringLocalizerFactory, CacheStringLocalizerFactory>();
return builder;
}
}
Step 7: Configure ApiService Program.cs
Now, let's update our API service's Program.cs
to use our localization system:
var builder = WebApplication.CreateBuilder(args);
// Add service defaults & Aspire integrations
builder.AddServiceDefaults();
builder.AddRedisDistributedCache(connectionName: "cache");
builder.AddLocalizationDefaults(); // Our custom extension
// Add services to the container
builder.Services.AddControllers();
builder.Services.AddOpenApi();
var app = builder.Build();
// Request localization must be early in the pipeline
app.UseRequestLocalization();
// Configure the HTTP request pipeline
app.UseExceptionHandler();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.MapControllers();
app.MapDefaultEndpoints();
app.Run();
Step 8: Configure AppHost Program.cs
In our AppHost project, we need to set up Redis:
var builder = DistributedApplication.CreateBuilder(args);
// Add Redis for distributed caching
var cache = builder.AddRedis("cache").WithRedisInsight().WithRedisCommander();
// ApiService depends on cache
var apiService = builder.AddProject<Projects.GlobeShop_ApiService>("apiservice")
.WithReference(cache)
.WaitFor(cache);
// Web frontend depends on ApiService and cache
builder.AddProject<Projects.GlobeShop_Web>("webfrontend")
.WithExternalHttpEndpoints()
.WithReference(cache)
.WaitFor(cache)
.WithReference(apiService)
.WaitFor(apiService);
builder.Build().Run();
Step 9: Create a Test Controller
Finally, let's create a simple controller to test our localization:
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
namespace GlobeShop.ApiService.Controllers;
[ApiController]
[Route("products")]
public class ProductsController : ControllerBase
{
private readonly IStringLocalizer<ProductsController> _localizer;
private readonly ILogger<ProductsController> _logger;
public ProductsController(
ILogger<ProductsController> logger,
IStringLocalizer<ProductsController> localizer)
{
_localizer = localizer;
_logger = logger;
}
[HttpGet]
public IActionResult GetProducts()
{
// No culture handling here - the middleware does it for us!
return Ok(new
{
title = _localizer["ProductsTitle"].Value,
products = new[]
{
new { id = 1, name = "Laptop", price = 999.99m, action = _localizer["AddToCart"].Value },
new { id = 2, name = "Smartphone", price = 699.99m, action = _localizer["AddToCart"].Value }
}
});
}
}
Testing It Out
Now let's test our localization. Start the app and make these requests:
- English (default):
GET /products
- Arabic:
GET /products?culture=ar
- With Accept-Language:
GET /products
with headerAccept-Language: ar
For the Arabic requests, you should see the Arabic translations of "Our Products" and "Add to Cart" in the response.
How It All Works
This setup gives us a clean way to handle localization in our .NET Aspire application:
- The
LocalizationLoader
reads embedded JSON files during startup and puts translations in Redis - When a request comes in, the
RequestLocalizationMiddleware
determines the culture from the query string or Accept-Language header - Our
CacheStringLocalizer
looks up translations from Redis based on the current culture - Controllers simply use
_localizer["Key"]
to get the correct translation without worrying about the culture determination logic
The beauty of this approach is that all the culture detection happens at the HTTP middleware level, keeping your controllers clean and focused on business logic.
Happy coding!