Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Setting html meta tags with Blazor

This is in reference to #10450.

Goal: Set meta tags (title, description etc) for SEO and Open Graph purposes with data coming from the page itself. Using the Javascript interop won't help as pages won't be able to be crawled.

I have used a suggestion by @honkmother and moved the base component further up the tree to encapsulate the <html> tag but for some reason this has affected routing. All links are prepended with ~/ and I can't seem to understand why.

I have created an example repo here if anyone is intersted in taking a look.

_Hosts.cshtml

@page "/"
@namespace BlazorMetaTags.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
    Layout = null;
}

<!DOCTYPE html>
<component type="typeof(AppBase)" render-mode="ServerPrerendered" />

AppBase.cs

using BlazorMetaTags.Shared;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;

namespace BlazorMetaTags
{
    public class AppBase : ComponentBase
    {
        protected override void BuildRenderTree(RenderTreeBuilder builder)
        {
            builder.OpenElement(0, "html");
            builder.AddAttribute(1, "lang", "en");

            builder.OpenElement(2, "head");
            builder.OpenComponent<Head>(3);
            builder.CloseComponent();
            builder.CloseElement();

            builder.OpenElement(3, "body");

            builder.OpenElement(4, "app");
            builder.OpenComponent<App>(5);
            builder.CloseComponent();
            builder.CloseElement();

            builder.OpenComponent<Body>(6);
            builder.CloseComponent();

            builder.AddMarkupContent(7, " <script src='_framework/blazor.server.js'></script>");
            builder.CloseElement();
            builder.CloseElement();

        }

    }

    public class MetaTags
    {
        public string Title { get; set; } = "";

        public string Description { get; set; } = "";
    }
}

Head.razor component to set the meta tags

@inject AppState _appState

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />

    <title>@_appState.MetaTags.Title</title>
    <meta name="description" content="@_appState.MetaTags.Description">

    <base href="~/" />
    <link rel="stylesheet" href="/css/bootstrap/bootstrap.min.css" />
    <link href="/css/site.css" rel="stylesheet" />
</head>

@code {

    protected override async Task OnInitializedAsync()
    {
        _appState.OnChange += StateHasChanged;
    }

}

Body.razor

<div id="blazor-error-ui">
    <environment include="Staging,Production">
        An error has occurred. This application may no longer respond until reloaded.
    </environment>
    <environment include="Development">
        An unhandled exception has occurred. See browser dev tools for details.
    </environment>
    <a href="" class="reload">Reload</a>
    <a class="dismiss">🗙</a>
</div>

AppState.cs

using System;

namespace BlazorMetaTags
{
    public class AppState
    {
        public MetaTags MetaTags { get; private set; } = new MetaTags();

        public event Action OnChange;

        public void SetMetaTags(MetaTags metatags)
        {
            MetaTags = metatags;
            NotifyStateChanged();
        }

        private void NotifyStateChanged() => OnChange?.Invoke();
    }
}

Startup.cs

public void ConfigureServices(IServiceCollection services)
{
    services.AddRazorPages();
    services.AddServerSideBlazor();
    services.AddScoped<AppState>();
    services.AddSingleton<WeatherForecastService>();
}
like image 852
Adrian Brink Avatar asked Nov 06 '22 11:11

Adrian Brink


1 Answers

I offer my decision on this issue. This solution has several advantages:

  1. SEO data will always be up-to-date WITHOUT using "_framework/blazor.server.js" by Blazor Server, which will allow you to get up-to-date SEO data for various bot programs, including Postman, curl and other non-browser programs.
  2. There is no need to use external libraries like DevExpress Free Blazor Utilities and Dev Tools or other similar.
  3. There is no need to overload the entire head tag. Since when you overload the entire head tag and use external CSS styles, the following happens: during the first rendering, the CSS styles do not work, but start working after the second rendering, which leads to the page blinking, since the CSS styles start working after the second rendering.

Here is my tested and working solution:

  1. File "_Host.cshtml":
@page "/"
@namespace App.Pages
@using App.Components
@using App.Helpers
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

@{
  Layout = null;
  string path = UrlHelper.GetLastPath(this.HttpContext.Request.Path);
  var (title, keywords, description, canonical) = UrlHelper.GetSeoData(path);
}

<!DOCTYPE html>
<html lang="en">
<head>
  <base href="/" />
  @*SEO*@
  <component type="typeof(TitleTagComponent)" render-mode="Static" param-Content=@title />
  <component type="typeof(KeywordsMetaTagComponent)" render-mode="Static" param-Content=@keywords />
  <component type="typeof(DescriptionMetaTagComponent)" render-mode="Static" param-Content=@description />
  <component type="typeof(CanonicalMetaTagComponent)" render-mode="Static" param-Content=@canonical />
  @*Extra head tag info*@
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <meta name="robots" content="index, follow">
  <meta name="author" content="App.com">
  <meta name="copyright" lang="en" content="App.com">
  @*Site icons*@
  <link rel="icon" href="favicon.ico" type="image/x-icon">
  @*External CSS*@
  <link rel="stylesheet" href="css/bootstrap.min.css" />
</head>

<body>
  <component type="typeof(App)" render-mode="ServerPrerendered" />

  <div id="blazor-error-ui">
    <environment include="Staging,Production">
      Server connection error. Refresh the page.
    </environment>
    <environment include="Development">
      An unhandled exception has occurred. See browser dev tools for details.
    </environment>
    <a href="" class="reload">Reload</a>
    <a class="dismiss">🗙</a>
  </div>

  <script src="_framework/blazor.server.js"></script>

</body>
</html>
  1. File "TitleTagComponent.cs"
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;

namespace App.Components {
  public class TitleTagComponent : ComponentBase {
    [Parameter]
    public string Content { get; set; }

    protected override void BuildRenderTree(RenderTreeBuilder builder) {
      base.BuildRenderTree(builder);
      builder.OpenElement(0, "title");
      builder.AddContent(1, Content ?? string.Empty);
      builder.CloseElement();
    }

  }
}
  1. File "KeywordsMetaTagComponent.cs"
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;

namespace App.Components {
  public class KeywordsMetaTagComponent : ComponentBase {
    [Parameter]
    public string Content { get; set; }

    protected override void BuildRenderTree(RenderTreeBuilder builder) {
      base.BuildRenderTree(builder);
      builder.OpenElement(0, "meta");
      builder.AddAttribute(1, "name", "keywords");
      builder.AddAttribute(2, "content", Content ?? string.Empty);
      builder.CloseElement();
    }

  }
}
  1. File "DescriptionMetaTagComponent.cs"
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;

namespace App.Components {
  public class DescriptionMetaTagComponent : ComponentBase {
    [Parameter]
    public string Content { get; set; }

    protected override void BuildRenderTree(RenderTreeBuilder builder) {
      base.BuildRenderTree(builder);
      builder.OpenElement(0, "meta");
      builder.AddAttribute(1, "name", "description");
      builder.AddAttribute(2, "content", Content ?? string.Empty);
      builder.CloseElement();
    }

  }
}
  1. File "CanonicalMetaTagComponent.cs"
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;

namespace App.Components {
  public class CanonicalMetaTagComponent : ComponentBase {
    [Parameter]
    public string Content { get; set; }

    protected override void BuildRenderTree(RenderTreeBuilder builder) {
      base.BuildRenderTree(builder);
      builder.OpenElement(0, "link");
      builder.AddAttribute(1, "rel", "canonical");
      builder.AddAttribute(2, "href", Content ?? string.Empty);
      builder.CloseElement();
    }

  }
}
  1. File "UrlHelper.cs"
using System;
using System.Text.RegularExpressions;

namespace App.Helpers {
  public static class UrlHelper {
    /// <summary>
    /// Regular expression to get all paths from short URL path without "/". Moreover, the first and last "/" may or may not be present. 
    /// Example: from the string "path1/path2/path3" - path1, path2 and path3 will be selected
    /// </summary>
    private static Regex ShortUrlPathRegex = new(@"^((?:/?)([\w\s\.-]+)*)*/*", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant);

    /// <summary>
    /// Retrieving the last path from short URL path based on a regular expression.
    /// </summary>
    /// <param name="path">Short URL path</param>
    /// <returns>Last path from short URL path</returns>
    public static string GetLastPath(string path) {
      if (string.IsNullOrWhiteSpace(path))
        return string.Empty;
      try {
        var match = ShortUrlPathRegex.Match(path);
        if (!match.Success)
          return string.Empty;
        return match.Groups[2].Value;
      }
      catch (Exception) {
        return string.Empty;
      }
    }

    /// <summary>
    /// Get data for SEO (title, keywords, description, canonical) depending on the path URL
    /// </summary>
    /// <param name="path">URL path</param>
    /// <returns>
    /// Tuple: 
    /// item1 - title 
    /// item2 - keywords 
    /// item3 - description 
    /// item4 - canonical
    /// </returns>
    public static (string, string, string, string) GetSeoData(string path) {
      string title = "App";
      string keywords = "app1, app2, app3";
      string description = "Default App description.";
      string canonical = "https://app.com";

      if (string.IsNullOrWhiteSpace(path))
        return (title, keywords, description, canonical);

      switch (path.ToLower()) {
        case "page1":
          title = "page1 on App.com";
          keywords = "page1, page1, page1";
          description = "Description for page1";
          canonical = "https://app.com/page1";
          return (title, keywords, description, canonical);

        case "page2":
          title = "page2 on App.com";
          keywords = "page2, page2, page2";
          description = "Description for page2";
          canonical = "https://app.com/page2";
          return (title, keywords, description, canonical);

        case "page3":
          title = "page3 on App.com";
          keywords = "page3, page3, page3";
          description = "Description for page3";
          canonical = "https://app.com/page3";
          return (title, keywords, description, canonical);
      }

      return (title, keywords, description, canonical);
    }

  }

}

According to a comment from Brad Bamford I have added an example of GetSeoDataDB method where I replaced switch with a query to the database for dynamically retrieve SEO data from DB depending on the page using Dapper:

    public static async Task<(string, string, string, string)> GetSeoDataDB(string path) {
      string title = "App";
      string keywords = "app1, app2, app3";
      string description = "Default App description.";
      string canonical = "https://app.com";

      if (string.IsNullOrWhiteSpace(path))
        return (title, keywords, description, canonical);

      var ConnectionString = "";
      var sql = $@"
SELECT *
FROM `SeoTable`
WHERE `Page` = '{path}'
;";
      using IDbConnection db = new MySqlConnection(ConnectionString);
      try {
        var result = await db.QueryFirstOrDefaultAsync<SeoModel>(sql);
        title = string.Join(", ", result.Title);
        keywords = string.Join(", ", result.Keywords);
        description = string.Join(", ", result.Description);
        canonical = string.Join(", ", result.Canonical);
      }
      catch (Exception ex) {
        return (title, keywords, description, canonical);
      }

      return (title, keywords, description, canonical);
    }
like image 185
Vitalii Vagin Avatar answered Nov 29 '22 07:11

Vitalii Vagin