Adrian Bodin

SEO-Friendly Localization In Asp.Net Core Razor Pages

Image of the alt here!
Photo by Lukas S on Unsplash

What is localization and why do we need it?


Localization is the process of making a application support different languages. This can be very important for certain kinds of applications, because it leaves a lot of benefits to both conversions and site traffic. Some interesting metrics can be found in This blog post.

ASP.NET comes with alot of functionallity to make this easier to implement localization in our application, but it has one problem. The default implementation uses ?culture= query parameter to determan the culture. The problem with this is that it is not recommendend by google to use localization this way for SEO (Source).

Lets look into this and implement it the recommended way!

Project setup


Lets setup the application by running this command that sets up a razor pages application with asp.net core identity for individual authentication.

mkdir LocalizedApp
cd LocalizedApp
dotnet new sln -n LocalizedApp
dotnet new razor --auth Individual -n LocalizationApp
dotnet sln LocalizedApp.sln add LocalizedApp/LocalizedApp.csproj

Run these commands in the terminal or use your prefered IDE/text editor to create a razor pages project with individual accounts. This will by default use a sqlite database, that we will also use for this demo.

Setup cultures


I add my default culture and supported cultures to my appsettings.json. I will use swedish and english as supported cultures in this example because i am a native swedish speaker.😄

{
  "ConnectionStrings": {
    "DefaultConnection": "DataSource=app.db;Cache=Shared"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "Localization": {
    "SupportedCultures": ["en-US", "sv-SE"],
    "DefaultCulture": "en-US"
  }
}

After that, lets configure our Program.cs for the cultures.

builder.Services.AddRazorPages()
                .AddViewLocalization()
                .AddDataAnnotationsLocalization();

//Tells the applicatino where we will store our .resx files for the localization.
builder.Services.AddLocalization(o => o.ResourcesPath = "Resources");

//Gets the cultures from appsettings.json/appsettings.Development.json.
var supportedCultures = builder.Configuration.GetSection("Localization:SupportedCultures").Get<string[]>();
var defaultCulture = builder.Configuration.GetValue<string>("Localization:DefaultCulture");

builder.Services.Configure<RequestLocalizationOptions>(options =>
{
    options.DefaultRequestCulture = new RequestCulture(defaultCulture!);
    options.AddSupportedCultures(supportedCultures!);
    options.AddSupportedCultures(supportedCultures!);
});

Also add this middleware to the request pipeline. Under app.UseRouting();.

app.UseRequestLocalization();

Creating the resources


Now lets setup the .resx files for the default and swedish language. Keep in mind, The folder structure of the Resources folder should match the folders it is used on.

Folder structure for resource files

After the folders and files are created we need to fill in some values. Now it really helps to have a good IDE like Jetbrains Rider or Visual Studio 2022 because they have a nice interface to fill out language values. In Jetbrains Rider that i am using they also have export/import as CSV functionallity that can be super helpful when getting help from Chat GPT for translations for example.

Jetbrains rider .resx editor

Using localization for razor views


First of all we need to include IViewLocalizer in our global _ViewImports.cshtml. By doing this, we can use the localizer between all our views.

@inject IViewLocalizer Localizer

After that, lets modify our Index.cshtml view.

@page
@model IndexModel
@{
    ViewData["Title"] = "Home page";
}

<div class="text-center">
    <h1 class="display-4">@Localizer["hero-heading"]</h1>
</div>

Now, start the application and go to ?culture=sv-SE

Image of the result of the localization

Look, our localization is working, nice! But there is still some pieces missing.

  1. We dont want to have our culture as a query paramenter for SEO reasons. We want to apply our culture after the root route of every request.

  2. For a better user experience, we want to automaticly set the culture depending on their prefered language in the browser.

Modifying routes


Now lets look into how we can modify our routes and get rid of the query parameter. Modify your Program.cs to this.

builder.Services.AddRazorPages()
    .AddRazorPagesOptions(o =>
    {
        o.Conventions.Add(new CustomCultureRouteModelConvention());
    })
    .AddViewLocalization()
    .AddDataAnnotationsLocalization();

builder.Services.AddLocalization(o => o.ResourcesPath = "Resources");

var supportedCultures = builder.Configuration.GetSection("Localization:SupportedCultures").Get<string[]>();
var defaultCulture = builder.Configuration.GetValue<string>("Localization:DefaultCulture");

builder.Services.Configure<RequestLocalizationOptions>(options =>
{
    options.DefaultRequestCulture = new RequestCulture(defaultCulture!);
    options.AddSupportedCultures(supportedCultures!);
    options.AddSupportedUICultures(supportedCultures!);
    options.RequestCultureProviders.Insert(0, new RouteDataRequestCultureProvider { Options = options});
});

By doing this, we add a custom route convention in razor pages, not really the same for how your would do route conventions in MVC. We also have create our class that is used as the convention, create a new file and name it CustomCultureRouteModelConvention.cs. Inside this file we want to be able to apply a optional culture route convention.

public class CustomCultureRouteModelConvention : IPageRouteModelConvention
{
    public void Apply(PageRouteModel model)
    {
        foreach (var selector in model.Selectors.ToList())
        {
            model.Selectors.Add(new SelectorModel
            {
                AttributeRouteModel = new AttributeRouteModel
                {
                    Order = -1,
                    Template = AttributeRouteModel.CombineTemplates("{culture?}", selector.AttributeRouteModel.Template),
                }
            });
        }
    }
}

By doing this, we should be able to go to /sv-SE and see the translated site without the query parameter.

Image of the result of the localization

Nice, it works!

But we still have some problems.

  1. If i click a link, the route culture route parameter is gone, we want it to persist all throughout the use of the application.

  2. The culture is still not choosen automaticly for the prefered language in the browser.

Lets fix both of these by creating a custom middleware.

Culture middleware


Create a folder in the root of the project called Middlewares, then a file called RequestCultureMiddleware.cs. The middleware can look like this.


public class RequestCultureMiddleware
{
    private readonly RequestDelegate _next;
    private readonly List<string> _supportedCultures;

    public RequestCultureMiddleware(RequestDelegate next, IConfiguration configuration)
    {
        _next = next;
        _supportedCultures = configuration.GetSection("Localization:SupportedCultures").Get<List<string>>();
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var path = context.Request.Path.Value;

        if (context.Request.Method == HttpMethod.Post.Method)
        {
            // If it's a POST request, remove the culture prefix and continue
            var pathSegments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
            if (pathSegments.Length > 1 && _supportedCultures.Contains(pathSegments[0]))
            {
                // Remove the culture prefix
                context.Request.Path = new PathString("/" + string.Join('/', pathSegments.Skip(1)));
            }

            await _next(context);
            return;
        }

        //Gets the value of the culture prefix if there is any
        var culturePrefix = path.Split('/', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();

        //if the culture prefix does not exist or the supported cultures does not contain it
        if (string.IsNullOrEmpty(culturePrefix) || !_supportedCultures.Contains(culturePrefix))
        {
            //Gets the preferred cultures from the header of the request
            var acceptLanguageHeader = context.Request.Headers["Accept-Language"].ToString();
            var preferredCultures = acceptLanguageHeader.Split(',')
                .Select(StringWithQualityHeaderValue.Parse)
                .OrderByDescending(s => s.Quality.GetValueOrDefault(1))
                .Select(s => s.Value)
                .ToList();

            //The culture is set to the preferred if it exists or en-US that is the default
            var userCulture = preferredCultures.FirstOrDefault(c => _supportedCultures.Contains(c)) ?? "en-US";

            //If the request does not start with the preferred culture it will redirect to the correct route
            if (!context.Request.Path.Value.StartsWith($"/{userCulture}", StringComparison.OrdinalIgnoreCase))
            {
                var redirectPath = $"/{userCulture}{context.Request.Path.Value}";
                var queryString = context.Request.QueryString.Value;
                context.Response.Redirect(redirectPath + queryString);
                return;
            }
        }

        // Call the next delegate/middleware in the pipeline.
        await _next(context);
    }
}

public static class RequestCultureMiddlewareExtensions
{
    public static IApplicationBuilder UseRequestCulture(
        this IApplicationBuilder builder, IConfiguration configuration)
    {
        return builder.UseMiddleware<RequestCultureMiddleware>(configuration);
    }
}

What is going on here?

  1. In the constuctor we provide the configuration to get the list of our supported cultures and also the next delegate to use after we are finished with our middleware to call the next middleware in the pipeline.

  2. The culture middleware runs.

  3. The next middleware in the pipeline is called.

EDIT:

The first part of the InvokeAsync method will remove the culture prefix on post requests. This is because i had some problems with using external logins like google because the prefix was added to the route. So this was necessary to make it work like it should.

Lets register the middleware now. Use static extension class for this. Below app.UseStaticFiles() add:

app.UseRequestCulture(app.Configuration);

This will hook up the middleware and register it in the pipeline. Then lets start the application and test.

Image of the result of the localization

This works great, even on the identity pages that you can translate to if you scaffold them.

Things to consider


Most user probably want to websites in the same language that they prefer in their browser, but if they want to use another language and hardcode it in the url, then the links will not work as expected. Because the middleware will redirect automaticlly to your prefered one.

There is one quick but maybe not the best solution to this that i know. Add this to every a tag in you html.

asp-route-culture="@CultureInfo.CurrentCulture.Name"

This will prefix the a tag to the culture that was hardcoded in the url and not use the Accept-Language header value.

But, there is probably better solutions than this.

Conclusion


ASP.NET makes it relativly easy to implement localization in our application with alot of helper classes to make it easier for us. There is also alot more about localization that you can read Here.

Hoped this helped anyone trying to implement a simular solution, i was stuck on this for several hours.

Thank you for reading!