Setting html meta tags with Blazor
Asked Answered
B

3

6

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>();
}
Bourdon answered 28/12, 2019 at 15:27 Comment(2)
Hi, Just change <base href="~/" /> to <base href="/" /> – Mislead
@MeisamDehghan thank you! This comment is gold. I kept having black pages when I used links, now it's perfect. – Henrieta
B
0

As suggested by Meisam Dehghan in a comment, the correct solution is to change

<base href="~/" />

to

<base href="/" />

This worked for me!

Bourdon answered 2/5, 2020 at 13:8 Comment(0)
J
2

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);
    }
Jennyjeno answered 17/4, 2021 at 19:33 Comment(3)
This is interesting, but it looks like it requires a static code database of every page in your site which isn't very scalable. It would be nicer to be able to define these values at the Blazor component page level. This would also allow for dynamic values. – Lafontaine
I showed a simplified version to show the possibility of changing the _Host.cshtml file before the second rendering of the components. To dynamically retrieve data for SEO from the database, you can change the GetSeoData method as you like. For example, in my project I use Dapper to work with the database, and inside GetSeoData I make queries to the database depending on the page and dynamically return the data corresponding to the desired page. – Jennyjeno
I have added to my post an example of the GetSeoDataDB method for working with a database. – Jennyjeno
B
1

Anyone who still need a shorter approach, there is a simple solution for it from .NET 7:

<HeadContent>
    <meta name="description" content="@Description">
</HeadContent>

@code {
    private string Description = "Description set by component";
}

Now, you can use any type of metatag with different different values on each page.

You can find more on MS Learn page: https://learn.microsoft.com/en-us/aspnet/core/blazor/components/control-head-content?view=aspnetcore-7.0

Boast answered 18/10, 2023 at 12:57 Comment(0)
B
0

As suggested by Meisam Dehghan in a comment, the correct solution is to change

<base href="~/" />

to

<base href="/" />

This worked for me!

Bourdon answered 2/5, 2020 at 13:8 Comment(0)

© 2022 - 2025 β€” McMap. All rights reserved.