Razor Pages Localisation - SEO-friendly URLs

This is the fourth article in a series that explores various aspects of localisation in ASP.NET Core Razor Pages applications. This article concentrates on the SEO aspects of localisation as they pertain to URL management, and how to utilise the RouteDataRequestCultureProvider to help you manage that.

The first article looked at how to work with cultures, and discussed the default RequestCultureProvider components that are used to determine the culture for the current request - components that work with the query string, cookie and request headers. The approach used in that article to demonstrate setting the culture for the request relies on setting query string values to generate locale-specific URLs. While it works, it is not the recommended approach. Google recommend a number of other approaches, one of which involves making use of sub-directories for locale-specific versions of the site content. Sub-directories should be named for the culture code that each version represents, so you end up with URL segments named after the specific culture: mydomain.com/en/about and mydomain.com/it/about etc.

This is all well and good, but it will result in an application with a LOT of duplication, effectively a completely new version for each supported culture in each sub-directory. ASP.NET Core is a lot more clever than that. It supports the notion of route data - application data passed as a segment in the URL. ASP.NET Core also provides a request culture provider that works with route data - the RouteDataRequestCultureProvider. This article builds on the application introduced in the previous articles to demonstrate how to make best use of this component.

The application in this article is the same one that has featured in the previous articles. It's built using the standard Razor Pages 3.1 project template with no authentication. Many of the concepts in this article were originally introduced in the previous articles, so you should read those first if you haven't already.

You need to add a route data item representing the culture to all routes. You could apply this as a route template to each page individually, but that approach is not really scalable. A better approach is to use a PageRouteModelConvention to add additional route templates to those that are generated by default for all pages in the application. The following PageRouteModelConvention creates a new attribute route for each existing route by combining a placeholder for a route data item named culture with the existing attribute route. The Order property of the AttributeRouteModel is set to -1 to ensure that it is processed first:

using Microsoft.AspNetCore.Mvc.ApplicationModels;
 
namespace Localisation.RouteModelConventions
{
    public class CultureTemplatePageRouteModelConvention : IPageRouteModelConvention
    {
        public void Apply(PageRouteModel model)
        {
            var selectorCount = model.Selectors.Count;
 
            for (var i = 0; i < selectorCount; i++)
            {
                var selector = model.Selectors[i];
 
                model.Selectors.Add(new SelectorModel
                {
                    AttributeRouteModel = new AttributeRouteModel
                    { 
                        Order = -1,
                        Template = AttributeRouteModel.CombineTemplates("{culture?}", selector.AttributeRouteModel.Template),
                    }
                });
            }
        }
    }
}

By convention, all RequestCultureProviders look for items with a key of culture and/or ui-culture by default to establish the culture of the current request. You can change this through configuration.

The custom PageRouteModelConvention is registered in ConfigureServices as part of RazorPagesOptions:

public void ConfigureServices(IServiceCollection services)
{
    services.AddRazorPages(options => {
        options.Conventions.Add(new CultureTemplatePageRouteModelConvention());
    });
}

If you run the application at this stage, you can test that the new attribute routes are working by manually inserting a culture code into the

Localization in Razor Pages

Routes are resolved correctly, but the culture for the request is not affected by the presence of the culture route data value. The RouteDataRequestCultureProvider needs to be registered as part of the RequestLocalizationOptions in ConfigureServices:

services.Configure<RequestLocalizationOptions>(options =>
{
    var supportedCultures = new[]
    {
        new CultureInfo("en"),
        new CultureInfo("de"),
        new CultureInfo("fr"),
        new CultureInfo("es"),
        new CultureInfo("ru"),
        new CultureInfo("ja"),
        new CultureInfo("ar"),
        new CultureInfo("zh"),
        new CultureInfo("en-GB")
    };
    options.DefaultRequestCulture = new RequestCulture("en-GB");
    options.SupportedCultures = supportedCultures;
    options.SupportedUICultures = supportedCultures;
    options.RequestCultureProviders.Insert(0, new RouteDataRequestCultureProvider { Options = options });
});

The code above is from the first article in the series, with the addition of the final line which registers the route data request culture provider, and inserts it as the first request provider to be used for culture matching. Now if you run the application again, you should see that request provider working:

Localization in Razor Pages

Generating Links With Tag Helpers

The links at the top of the page are generated by anchor tag helpers in the layout page. Prior to Razor Pages 2.2, anchor tag helpers would make use of ambient route values to generate URLs. If the target page and the current request share the same route value within their attribute route, values from the current request are automatically reused within URL generation. This is very useful when every page shares the culture route value as in this example. However, this behaviour also has some unwanted side effects, and was largely removed with the introduction of endpoint routing in ASP.NET Core 2.2. Ambient route values are now only used if the target page specified by the asp-page attribute in the anchor tag is the same as the current request. You can see this by navigating to the Contact page, selecting a culture (French, in my case) and looking at the generated HTML for the links:

Notice that only the contact page's anchor tag includes the ambient route value for culture. It is not included in the links generated for the home page or the privacy page. The culture route value needs to be added explicitly:

<ul class="navbar-nav flex-grow-1">
    <li class="nav-item">
        <a class="nav-link text-dark" asp-route-culture="@Context.Request.RouteValues["culture"]" asp-page="/Index">@localizer.Get("Home")</a>
    </li>
    <li class="nav-item">
        <a class="nav-link text-dark" asp-route-culture="@Context.Request.RouteValues["culture"]" asp-page="/Contact">@localizer.Get("Contact")</a>
    </li>
    <li class="nav-item">
        <a class="nav-link text-dark" asp-route-culture="@Context.Request.RouteValues["culture"]" asp-page="/Privacy">@localizer.Get("Privacy")</a>
    </li>
</ul>

Now all links include the culture:

However, just like the prospect of manually generating route templates for each page, this is equally unscalable for large applications. What you can do instead is to implement your own anchor tag helper which derives from the framework version and is responsible for adding the culture route value as demonstrated in this Github issue.

The code for the tag helper follows:

[HtmlTargetElement("a", Attributes = ActionAttributeName)]
[HtmlTargetElement("a", Attributes = ControllerAttributeName)]
[HtmlTargetElement("a", Attributes = AreaAttributeName)]
[HtmlTargetElement("a", Attributes = PageAttributeName)]
[HtmlTargetElement("a", Attributes = PageHandlerAttributeName)]
[HtmlTargetElement("a", Attributes = FragmentAttributeName)]
[HtmlTargetElement("a", Attributes = HostAttributeName)]
[HtmlTargetElement("a", Attributes = ProtocolAttributeName)]
[HtmlTargetElement("a", Attributes = RouteAttributeName)]
[HtmlTargetElement("a", Attributes = RouteValuesDictionaryName)]
[HtmlTargetElement("a", Attributes = RouteValuesPrefix + "*")]
public class CultureAnchorTagHelper : AnchorTagHelper
{
    public CultureAnchorTagHelper(IHttpContextAccessor contextAccessor, IHtmlGenerator generator) :
        base(generator)
    {
        this.contextAccessor = contextAccessor;
    }
 
    private const string ActionAttributeName = "asp-action";
    private const string ControllerAttributeName = "asp-controller";
    private const string AreaAttributeName = "asp-area";
    private const string PageAttributeName = "asp-page";
    private const string PageHandlerAttributeName = "asp-page-handler";
    private const string FragmentAttributeName = "asp-fragment";
    private const string HostAttributeName = "asp-host";
    private const string ProtocolAttributeName = "asp-protocol";
    private const string RouteAttributeName = "asp-route";
    private const string RouteValuesDictionaryName = "asp-all-route-data";
    private const string RouteValuesPrefix = "asp-route-";
    private const string Href = "href";
 
    private readonly IHttpContextAccessor contextAccessor;
    private readonly string defaultRequestCulture = "en";
 
    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        var culture = (string)contextAccessor.HttpContext.Request.RouteValues["culture"];
 
        if (culture != null && culture != defaultRequestCulture)
        {
            RouteValues["culture"] = culture;
        }
 
        base.Process(context, output);
    }
}

Then you need to remove the existing anchor tag helper from the application, which is achieved by applying the removeTagHelper directive in _ViewImports

@removeTagHelper Microsoft.AspNetCore.Mvc.TagHelpers.AnchorTagHelper, Microsoft.AspNetCore.Mvc.TagHelpers

Finally, if you have been following the entire series of articles that look at localisation in Razor Pages, you may want to alter the client side code in the culture switcher ViewComponent introduced in the first article. It currently works with the query string request culture provider, so it requires amending to work with a URL segment rather than a query string value:

<script>
    var segments = location.pathname.split('/');
    var el = document.getElementById("culture-options");
    el.addEventListener("change", () => {
        var culture = el.options[el.selectedIndex].value;
        if (segments.length > 2) {
            segments[1] = culture;
            location.href = segments.join('/');
        } else {
            location.href = '/' + culture + location.pathname;
        }
    });
</script>

Summary

The RouteDataRequestCultureProvider is an essential component if you want to follow the recommendations for working with optimal locale-specific URLs in a Razor Pages application. It is not registered as one of the default request culture providers so it needs to be configured separately. This article has also shown how to use a PageRouteModelConvention to centrally manage locale-specific routing across an application, and how to circumvent the removal of ambient route values in ASP.NET Core 2.2 and still use an anchor tag helper to generate routes that incorporate the current culture for all pages in the application.