posh.wiki


Framework-agnostic internationalization in .NET

2026-03-21

Tags: .NET

If your app is marketed towards users in multiple countries, you might want to add translations to make it more accessible and appealing to users who prefer other languages.

This post details how to easily set up translations for any .NET app, with the added benefit of every resource being strongly typed for an additional layer of protection against accidental breaking changes.

Package Reference

You'll need to reference the Microsoft.Extensions.Localization NuGet package.

<PackageReference Include="Microsoft.Extensions.Localization" Version="10.0.3" />

Defining translations

First, define your default culture's resources. Under the folder /Resource/Languages, create a file with the .resx extension.

Note that a class will be generated from every .resx file at design time. For this reason, it's recommended to name them *Resources.resx (e.g. MyComponentResources.resx), so as not to confuse them with the existing classes.

The initial content of the file is a lot, but don't worry, you don't have to read or modify any of it.

<?xml version="1.0" encoding="utf-8"?>
<root>
  <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
    <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
    <xsd:element name="root" msdata:IsDataSet="true">
      <xsd:complexType>
        <xsd:choice maxOccurs="unbounded">
          <xsd:element name="metadata">
            <xsd:complexType>
              <xsd:sequence>
                <xsd:element name="value" type="xsd:string" minOccurs="0" />
              </xsd:sequence>
              <xsd:attribute name="name" use="required" type="xsd:string" />
              <xsd:attribute name="type" type="xsd:string" />
              <xsd:attribute name="mimetype" type="xsd:string" />
              <xsd:attribute ref="xml:space" />
            </xsd:complexType>
          </xsd:element>
          <xsd:element name="assembly">
            <xsd:complexType>
              <xsd:attribute name="alias" type="xsd:string" />
              <xsd:attribute name="name" type="xsd:string" />
            </xsd:complexType>
          </xsd:element>
          <xsd:element name="data">
            <xsd:complexType>
              <xsd:sequence>
                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
                <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
              </xsd:sequence>
              <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
              <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
              <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
              <xsd:attribute ref="xml:space" />
            </xsd:complexType>
          </xsd:element>
          <xsd:element name="resheader">
            <xsd:complexType>
              <xsd:sequence>
                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
              </xsd:sequence>
              <xsd:attribute name="name" type="xsd:string" use="required" />
            </xsd:complexType>
          </xsd:element>
        </xsd:choice>
      </xsd:complexType>
    </xsd:element>
  </xsd:schema>
  <resheader name="resmimetype">
    <value>text/microsoft-resx</value>
  </resheader>
  <resheader name="version">
    <value>2.0</value>
  </resheader>
  <resheader name="reader">
    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
  </resheader>
  <resheader name="writer">
    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
  </resheader>
</root>

To add a new resource, add the following.

  </resheader>
+ <data name="KeyToReference" xml:space="preserve">
+    <value>Text that will be shown</value>
+ </data>
</root>

To add an alternative culture, create another file with the same name, but with its ISO 639 Alpha 2 language code between the name and the extension. For example, MyComponentResources.resx adapted for French (fr) would become MyComponentResources.fr.resx. The file is structured exactly the same - the only thing that should differ is the values.

  </resheader>
+ <data name="KeyToReference" xml:space="preserve">
+    <value>Texte qui sera montré</value>
+ </data>
</root>

After you've defined your resources, you'll need to make them eligible for compilation. Visual Studio updates them automatically on save, but you can overwrite this behaviour and generate files manually for both a smaller csproj file and a better cross-IDE experience.

  <ItemGroup>
    <EmbeddedResource Update="Resources/Languages/*.resx">
      <Generator>MSBuild:Compile</Generator>
      <LastGenOutput>%(Filename).Designer.cs</LastGenOutput>
      <StronglyTypedFileName>$(MSBuildProjectDirectory)\Resources\Languages\%(Filename).Designer.cs</StronglyTypedFileName>
      <StronglyTypedLanguage>CSharp</StronglyTypedLanguage>
      <StronglyTypedNamespace>$(RootNamespace).Resources.Languages</StronglyTypedNamespace>
      <StronglyTypedClassName>%(Filename)</StronglyTypedClassName>
    </EmbeddedResource>
  </ItemGroup>

Since the Designer.cs files are generated (i.e., not source), you may want to exclude them from version control. Be wary that existing projects may have other Designer.cs files which should not be ignored.

Quick Aside: File Nesting

The number of resx files in your project should be equal to the number of supported cultures multiplied by the number of resource classes. This number can grow quickly. To make it more manageable, you can configure file nesting in your editor to group by the resource class.

In VSCode, under .vscode/settings.json, add the following

"explorer.fileNesting.enabled": true,
"explorer.fileNesting.expand": false,
"explorer.fileNesting.patterns": {
    "*.resx": "${capture}.*.resx",
}

For Visual Studio, it's a little more verbose, as there are no patterns or capture groups. In .filenesting.json:

{
  "root": false,
  "dependentFileProviders": {
    "add": {
      "extensionToExtension": {
        "add": {
          ".fr.resx": [ ".resx" ],
          ".es.resx": [ ".resx" ],
          // more languages here...
        }
      }
    }
  }
}

Using translations

Using a simple translation is as simple as referencing the static string property on your resource class, e.g. MyComponentResources.KeyToReference.

The getter will automatically resolve which culture to use based on CultureInfo.DefaultThreadCurrentCulture or CultureInfo.DefaultThreadCurrentUICulture

Note that if the value is empty or undefined, the resolver will fall back to the default culture.

Parameterised translations

Not all cultures put the same words in the same position, or even in the same order. Sometimes, you need to interpolate a value into an indeterminate position in a localised resource.

In your resource declaration, you use {0}, {1}, etc. in the declaration to add parameters. For example:

To use them, you'll first need to add localisation services to your dependency injection container. Thanks to the NuGet package we installed earlier, it's as simple as:

builder.Services.AddLocalization();

In the class where you want to render the parameterised resource, you'll need to inject a Microsoft.Extensions.Localization.IStringLocalizer<T>, where T is the name of your resx file with a namespace generated from the path, e.g. Resources.Languages.MyComponentResources.

Then, to render, use an indexer on the IStringLocalizer, with the resource key and any parameters. The first additional parameter will be placed at {0}, the second at {1}, etc. In our earlier example, _myStringLocalizer[MyComponentResources.ConfirmationMessage, fileName, emailAddress] will become:

Changing cultures at runtime

To change the culture at runtime, modify the properties on System.Globalization.CultureInfo for the culture you want. Note that you may need to trigger a re-render of the UI to update all resource strings.

var culture = new CultureInfo("de-DE");
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;