I offer my decision on this issue. This solution has several advantages:
- 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.
- There is no need to use external libraries like DevExpress Free Blazor Utilities and Dev Tools or other similar.
- 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:
- 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>
- 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();
}
}
}
- 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();
}
}
}
- 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();
}
}
}
- 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();
}
}
}
- 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);
}