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>();
}
I offer my decision on this issue. This solution has several advantages:
Here is my tested and working solution:
@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>
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();
}
}
}
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();
}
}
}
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();
}
}
}
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();
}
}
}
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);
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With