Multilingual Web Application based on Avalonia Framework
Posted on August 7, 2025
This article describes how to approach creation of a multilingual web application with Avalonia framework such that user can change language dynamically at any time.
In order to make language translation work within a Web App, it is not going to be enough to just localize your resources (strings for the most part) as explained here: https://docs.avaloniaui.net/docs/guides/implementation-guides/localizing
You will also have to pull a few more tricks from the sleeve, which could be summarized like this:
- Have control over which language is selected as current.
 - Ensure, that there is a “neutral language” available in your app, which the app will use in case no other languages are available to it.
 - Give user choice to choose any language your application supports and change it dynamically during run-time.
 - Upon initialization of the application, find what the current language of user’s browser is. Then see, if your web application supports that language. If it does, then pre-select it. If it does not, then switch to neutral language.
 - Assuming, that most of your GUI is written in AXAML, have a mechanism, that will allow you to bind your AXAML to properties, the return values of which will be the translated strings (based on the currently chosen language), so that you don’t have to code any translation logic into code-behind of your views.
 - The entire language translation software layer should be globally accessible, because every now and then, you may need to retrieve a translated string from within your application logic.
 - Create a piece of GUI which will allow the user to switch currently chosen language. This “language picker” should include flags of countries of supported languages.
 - Last, but not least, ensure, that CLR loads satellite assemblies.
 
Let’s go!
Step 1: Prepare your resources
The first step is rather easy and well-documented on Avalonia web site. I am going to recap very briefly the most important points. Once you are done preparing your resources, you should:
- Be able to build the application (obviously).
 - For each resource file (resx extension) which you added, set its Build Action to Embedded resource.
 - The main resource file (the one, which does not contain any language abbreviation in its file name) should have a Custom Tool set to PublicResXFileCodeGenerator.
 - Do not update your OnFrameworkInitializationCompleted() method in App.axaml.cs file with the code which sets (updates) the Culture property of your resource object. We will use a different approach.
 
Step 2: Creation of TranslationService
The heart of our multilingual system is a TranslationService class.
Its job is simple: store the available languages, track which one is active, and make it easy for the UI to pull the correct translations.
Here’s what our service must do:
- Keep a list of supported languages (Language objects).
 - Track the currently selected language.
 - Have a default language (the “neutral” fallback).
 - Fire an event whenever the user changes the language.
 - Let us switch languages at runtime.
 - Provide an easy way for AXAML views to bind directly to translated strings.
 - Be accessible anywhere in the app.
 
The ITranslationService interface
We’ll define an interface so our implementation is clean and swappable:
public interface ITranslationService
{
    event EventHandler? LanguageChanged;
    IEnumerable Languages { get; }
    Language SelectedLanguage { get; }
    Language DefaultLanguage { get; }
    void SetLanguage(string sLanguageCode);
    string this[string sKey] { get; }
}
What this does:
- LanguageChanged — lets views refresh when the language changes.
 - Languages — the supported languages list.
 - SetLanguage() — changes the current language by code.
 - The indexer (this[string key]) — quick way to fetch translated text by resource key.
 
The Language class
A Language object stores metadata for a single supported language:
public class Language
{
// Store language string identifier, e.g. "en-EN", "de-DE", etc.
public virtual required string Name { get; init; }
// To be displayed in GUI
public virtual required string Text { get; init; }
// Path to the SVG flag icon within avalonia resources for the language
public virtual required string SvgPath { get; init; }
public override bool Equals(object? obj) =>
obj is Language otherLanguage ? this.Name == otherLanguage.Name : base.Equals(obj);
public override int GetHashCode() =>
this.Name?.GetHashCode() ?? base.GetHashCode();
}
Why this matters
- Name is the unique language code (used internally).
 - Text is shown to the user in the language picker.
 - SvgPath points to the embedded flag icon.
 - Equality overrides make it easy to compare languages.
 
The TranslationService implementation
class TranslationService : ITranslationService
{
    private Language m_SelectedLanguage;
    public TranslationService()
    {
        Languages =
                [
                    new() {
            Text = "English",
            SvgPath = "avares://BeeMobile.AppLogic/Assets/Lang/flags/us.svg",
            Name = "en-US"
        },
        new() {
            Text = "Deutsch",
            SvgPath = "avares://BeeMobile.AppLogic/Assets/Lang/flags/de.svg",
            Name = "de-DE"
        },
        new() {
            Text = "Français",
            SvgPath = "avares://BeeMobile.AppLogic/Assets/Lang/flags/fr.svg",
            Name = "fr-FR"
        },
        new() {
            Text = "Español",
            SvgPath = "avares://BeeMobile.AppLogic/Assets/Lang/flags/es.svg",
            Name = "es-ES"
        },
        new() {
            Text = "Slovenský",
            SvgPath = "avares://BeeMobile.AppLogic/Assets/Lang/flags/sk.svg",
            Name = "sk-SK"
        },
        new() {
            Text = "Český",
            SvgPath = "avares://BeeMobile.AppLogic/Assets/Lang/flags/cs.svg",
            Name = "cs-CZ"
        },
        new() {
            Text = "Polski",
            SvgPath = "avares://BeeMobile.AppLogic/Assets/Lang/flags/pl.svg",
            Name = "pl-PL"
        },
        new() {
            Text = "Magyar",
            SvgPath = "avares://BeeMobile.AppLogic/Assets/Lang/flags/hu.svg",
            Name = "hu-HU"
        },
    ];
        this.m_SelectedLanguage = this.DefaultLanguage;
    }
    public event EventHandler? LanguageChanged;
    protected virtual void OnLanguageChanged(EventArgs e) =>
        LanguageChanged?.Invoke(this, e);
    public virtual IEnumerable Languages { get; }
    public virtual Language SelectedLanguage => m_SelectedLanguage;
    public virtual Language DefaultLanguage => Languages.First(l => l.Name == "en-US"); // Default to English if not found
    public void SetLanguage(string sLanguageCode)
    {
        if (sLanguageCode == null)
            throw new ArgumentNullException(nameof(sLanguageCode), "Selected language cannot be null.");
        if (!Languages.Any(l => l.Name == sLanguageCode) && !Languages.Any(l => l.Name.StartsWith(sLanguageCode)))
            throw new LanguageNotFoundException($"The selected language is not available in the list of languages:{sLanguageCode}");
        Language? langToSet = Languages.FirstOrDefault(l => l.Name == sLanguageCode) ?? Languages.FirstOrDefault(l => l.Name.StartsWith(sLanguageCode));
        if (langToSet == null && !this.m_SelectedLanguage.Equals(this.DefaultLanguage))
        {
            this.m_SelectedLanguage = this.DefaultLanguage;
            this.OnLanguageChanged(EventArgs.Empty);
        }
        else if (langToSet != null && !this.m_SelectedLanguage.Equals(langToSet))
        {
            this.m_SelectedLanguage = langToSet;
            this.OnLanguageChanged(EventArgs.Empty);
        }
        Assets.Lang.Resources.Culture = new CultureInfo(this.m_SelectedLanguage.Name);
    }
    public virtual string this[string sKey] =>
            Assets.Lang.Resources.ResourceManager.GetString(sKey, Assets.Lang.Resources.Culture) ?? string.Empty;
}
Key points:
- We hardcode languages here for simplicity (you could load dynamically later).
 - SetLanguage() tries exact match, then partial match, then falls back to default.
 - The indexer means no extra property for every string — just bind to [key] in AXAML.
 
Result
We now have a single class that:
- Stores all supported languages.
 - Switches languages at runtime.
 - Lets UI elements bind to translated strings without extra boilerplate.
 - Notifies the whole app when the language changes.
 - Has control over which language is currently selected (point A of our assignment).
 - Defines a neutral language (point B of our assignment).
 
Step 3: Register TranslationService into ServiceHost
This article is not supposed to be about dependency injection. A very quick introduction to dependency injection (along with its alignment towards Avalonia framework, of course), can be found here: https://docs.avaloniaui.net/docs/guides/implementation-guides/how-to-implement-dependency-injection. For the sake of this article, we will also use Microsoft.Extensions.DependencyInjection together with: Microsoft.Extensions.Hosting. If you want to learn more about Dependency Injection, look at this article / https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection. And if you still feel like you wanna learn more, then look here – https://ardalis.com/new-is-glue/. Once you added both of the above mentioned NuGet packages to your project a class like this will come in handy:
public class ServiceHost
{
    private static ServiceHost s_Host;
    private static IHost s_HostInternal;
    public static ServiceHost Instance => s_Host;
    static ServiceHost()
    {
        HostApplicationBuilder builder = Host.CreateApplicationBuilder();
        builder.Services.AddSingleton<ITranslationService, TranslationService.TranslationService>();
        s_HostInternal = builder.Build();
        s_Host = new ServiceHost();
    }
    private ServiceHost()
    {        
    }
    public T? GetService() => s_HostInternal.Services.GetService();
}
This class implements point F of our assignment, which is to have language translation software layer – in our case represented by TranslationService globally accessible throughout application.
Step 4: Update your view models
Here I’m going to assume, that when building GUI of your app you are adhering to MVVM pattern (as described on Avalonia web site). This means, that whatever properties your views have, that are supposed to display text (strings), these can be bound to the underlying view model of your view. I’m also assuming, that you have some base view model in place, which all the other view models inherit from (if you don’t, you will have to create one). The base view model is the place, where we are going to insert a reference to our language translation layer in the form of a property. This is how it may look like:
public abstract class ViewModelBase : ReactiveObject // or 
{
    private ITranslationService m_TranslationService;
    protected ViewModelBase()
    {
        this.m_TranslationService = ServiceHost.Instance.GetService() ??
            throw new InvalidOperationException("Translation service is not available. Ensure it is registered in the service host.");
    }
    protected ITranslationService TranslationService => this.m_TranslationService;
    public string this[string sKey] => this.TranslationService[sKey];
}
The fact, that the above example uses a view model based on ReactiveUI is irrelevant. Important part is the TranslationService property, which gives us an instance of ITranslationService. Let us assume, we have a SelectableTextBlock in our view.The view (written in AXAML) can then be bound to translated properties with the following syntax …
<SelectableTextBlock
Classes="h1"
Margin="0,40,0,40"
HorizontalAlignment="Center"
Text="{Binding Path=[MainView_Heading]}" />
… where MainView_Heading is the name of resource (a translated string). The [MainView_Heading] syntax calls the TranslationService indexer.
From this point on, things get quite easy.
Step 5: Let the user change the language
A simple ComboBox works well as a language picker.
Here’s an example:
The UI: ComboBox with flags
<ComboBox
 x:Name="cmbLanguages" 
ItemsSource="{Binding Path=Languages}" 
SelectedItem="{Binding Path=SelectedLanguage}">
<ComboBox.ItemTemplate>
	<DataTemplate>
		<svg:Svg Path="{Binding Path=SvgPath}" Width="32" Height="32"/>
	</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
What’s happening here:
- ItemsSource comes from the list of Language objects in the view model.
 - SelectedItem is bound to the currently active language.
 - Each item shows a flag icon via SvgPath.
 
The ViewModel: Languages list & SelectedLanguage property
We connect the UI to TranslationService via two properties:
public IEnumerable Languages => this.TranslationService.Languages;
public Language SelectedLanguage
{
    get => this.TranslationService.SelectedLanguage;
    set
    {
        if (value != null && value != this.TranslationService.SelectedLanguage)
        {
            this.TranslationService.SetLanguage(value.Name);
            this.RaisePropertyChanged(nameof(SelectedLanguage));
            this.RaisePropertyChanged("Item");
        }
    }
}
Why this matters:
- Languages supplies the ComboBox with available options.
 - SelectedLanguage gets/sets the active language.
 - Calling RaisePropertyChanged(“Item”) ensures every text bound via the indexer updates instantly — no manual refresh needed. the real kicker here is raising PropertyChanged for “Item” property, which is our indexer. This is cruical piece of code which ensures, that all properties in our view, which are bound to translated strings via the indexer, will get refreshed. It is exactly this piece of code which accomplishes point C of our assignment.
 
Result:
We now have a dynamic, UI-driven language picker that:
- Displays flags for visual recognition.
 - Instantly switches translations without restarting.
 - Automatically refreshes all text that’s bound to translation keys.
 
This also wraps up language selector feature, which is the point G of our assignment.
Step 6: Match App Language to Browser Language
It’s nice when a web app feels “smart” — for example, opening in the user’s native language automatically.
We can do this by checking the browser’s language at startup, then selecting it if our app supports it.
Enable JavaScript Interop
We need to run a bit of JavaScript from .NET to read the browser language. Since JavaScript calls aren’t safe in themselves, the only special step is enabling unsafe code blocks in your .csproj file:
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
Write the JavaScript Function
Add a helper function to your main JavaScript module (by default main.js):
function GetBrowserLanguage() {
    return navigator.language.slice(0, 5);
};
Why “.slice(0, 5)”? It trims the language string to a standard format like “en-US” or “fr-FR”.
Register the Function with .NET Runtime
Tell the .NET runtime that this JavaScript function is available for import. Update your main.js with a code like this:
dotnetRuntime.setModuleImports('main.js',
{
    GetBrowserLanguage: GetBrowserLanguage
});
Create a C# Interop Wrapper
On the .NET side, create a static class to call GetBrowserLanguage():
[SupportedOSPlatform("browser")]
static partial class InteropJS
{
    [JSImport("GetBrowserLanguage", "main.js")]
    public static partial string GetBrowserLanguage();
}
Use It During App Initialization
In your OnFrameworkInitializationCompleted() method, run this code before the app fully loads:
try
{
    string sBrowserLang = InteropJS.GetBrowserLanguage();
    ServiceHost.Instance.GetService<ITranslationService>()?.SetLanguage(sBrowserLang);
}
catch (LanguageNotFoundException ex)
{
    // If browser language isn’t supported, TranslationService will
    // fall back to the default (neutral) language automatically.
}
Result:
When the app starts:
- It reads the browser’s language.
 - If supported, it sets that as the current app language.
 - If not supported, it gracefully falls back to the default language.
 
Pro tip: Later, you can enhance this by storing the user’s last selected language in a cookie. That way, the app prefers their manual choice over browser defaults.
Step 7: Load satellite assemblies
If you run your Avalonia app as a desktop build, your translations work perfectly.
But when you run it in the browser, it always stays in the default language, no matter which language you pick.
This is because the ASP.NET Core backend does not automatically load satellite assemblies in a WebAssembly environment.
Satellite assemblies are where .NET stores your localized resources (.resx files) for each culture.
If they’re not loaded, ResourceManager only sees the default (neutral) language. To fix this, we need to explicitly load the cultures our app supports at startup.
Import the JavaScript loader
Add this method to your Program class (or wherever your Main method is):
[JSImport("INTERNAL.loadSatelliteAssemblies")]
public static partial Task LoadSatelliteAssemblies(string[] culturesToLoad);
Call it before building your app
Right before your BuildAvaloniaApp() call, run:
await LoadSatelliteAssemblies(["cs-CZ", "de-DE", "es-ES", "hu-HU", "pl-PL", "sk-SK", "fr-FR" ]);
This array must list every culture code your app supports. If you miss one, translations for that language won’t load.
The input argument is an array of strings which identify the langauges your application supports. That’s it!