Still hard‑coding <title>
in index.html
and forgetting OpenGraph on half your pages? You’re silently throwing away clicks, previews, and shares.
In this post I’ll show you how to drive <title>
, <meta name="description">
, and OpenGraph/Twitter tags from route parameters in Blazor using HeadOutlet
. We’ll also build a tiny helper service that lets any page update SEO tags with one line, so your snippets and social previews always match the content on screen.
In short: Add
HeadOutlet
, render a singleMetaHeadRenderer
at the root, and callMeta.Set(...)
in pages to push dynamic<title>
/meta/OG based on route params (slug, id, query, etc.).
Why bother?
- Search CTR: An accurate title + description bump click‑through. Users see a promise that matches the page they’ll get.
- Social previews: OpenGraph/Twitter tags decide the card shown in Slack/Teams/Twitter/LinkedIn. Wrong tags → wrong card.
- Maintainability: Centralizing head management avoids duplicated, stale tags across components.
I hit this on a product catalog where the same <title>
leaked across routes after client‑side nav. The fix was small: let pages declare intent (desired tags) and have a single component translate that to <head>
.
Step 1 – Enable HeadOutlet
Blazor WebAssembly (.NET 6+)
// Program.cs
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
// This sits right after <head> and lets components render head content dynamically
builder.RootComponents.Add<HeadOutlet>("head::after");
await builder.Build().RunAsync();
Blazor Server (.NET 6/7)
In Pages/_Host.cshtml
put the outlet inside <head>
:
<head>
...
<component type="typeof(HeadOutlet)" render-mode="ServerPrerendered" />
</head>
Blazor Web App (.NET 8+)
You can still use HeadOutlet
and <HeadContent>
in Razor components (SSR + interactive). Place the outlet once in the layout used by your app (e.g., MainLayout.razor
).
<!-- MainLayout.razor -->
<HeadOutlet />
@Body
You only need one outlet in the app.
Step 2 – The simplest route‑driven tags (no service yet)
For many pages, PageTitle
and HeadContent
are enough. You can compute values from route parameters directly.
@page "/blog/{slug}"
@inject NavigationManager Nav
@inject IPostsRepository Posts
@code {
[Parameter] public string slug { get; set; } = default!;
private Post? post;
protected override async Task OnParametersSetAsync()
{
post = await Posts.BySlugAsync(slug);
}
}
@if (post is null)
{
<p>Loading…</p>
}
else
{
<PageTitle>@post.Title — MyBlog</PageTitle>
<HeadContent>
<meta name="description" content="@post.Excerpt" />
<meta property="og:title" content="@post.Title" />
<meta property="og:description" content="@post.Excerpt" />
<meta property="og:type" content="article" />
<meta property="og:url" content="@Nav.Uri" />
@if (!string.IsNullOrWhiteSpace(post.SocialImageUrl))
{
<meta property="og:image" content="@post.SocialImageUrl" />
<meta name="twitter:card" content="summary_large_image" />
}
<link rel="canonical" href="@Nav.Uri" />
</HeadContent>
<h1>@post.Title</h1>
<article>@((MarkupString)post.Html)</article>
}
This works, but you’ll quickly repeat tag markup across pages. Let’s centralize it.
Step 3 – A tiny MetaService
+ single renderer
We’ll push desired metadata into a scoped service and render it once near the root. Pages just call Meta.Set(...)
.
Model
// MetaModel.cs
public record MetaModel(
string? Title = null,
string? Description = null,
string? CanonicalUrl = null,
string? OgTitle = null,
string? OgDescription = null,
string? OgImage = null,
string? OgType = "article",
string? OgUrl = null,
string? TwitterCard = "summary_large_image");
Service
// MetaService.cs
using Microsoft.AspNetCore.Components;
public sealed class MetaService
{
private readonly NavigationManager _nav;
private readonly MetaModel _defaults;
public MetaModel Current { get; private set; }
public event Action? Changed;
public MetaService(NavigationManager nav)
{
_nav = nav;
_defaults = new(
Title: "MyApp",
Description: "MyApp: fast, tiny, pragmatic.",
CanonicalUrl: nav.BaseUri.TrimEnd('/'),
OgTitle: "MyApp",
OgDescription: "MyApp default description",
OgType: "website",
OgUrl: nav.BaseUri.TrimEnd('/'),
TwitterCard: "summary_large_image");
Current = _defaults;
}
public void Set(MetaModel model)
{
// Fill missing with previous values to make incremental calls easy
Current = Current with
{
Title = model.Title ?? Current.Title,
Description = model.Description ?? Current.Description,
CanonicalUrl = model.CanonicalUrl ?? Current.CanonicalUrl,
OgTitle = model.OgTitle ?? model.Title ?? Current.OgTitle,
OgDescription = model.OgDescription ?? model.Description ?? Current.OgDescription,
OgImage = model.OgImage ?? Current.OgImage,
OgType = model.OgType ?? Current.OgType,
OgUrl = model.OgUrl ?? model.CanonicalUrl ?? Current.OgUrl,
TwitterCard = model.TwitterCard ?? Current.TwitterCard
};
Changed?.Invoke();
}
public void Reset()
{
Current = _defaults;
Changed?.Invoke();
}
}
Register it:
// Program.cs or in DI config
builder.Services.AddScoped<MetaService>();
One renderer near the root
<!-- MetaHeadRenderer.razor -->
@implements IDisposable
@inject MetaService Meta
<HeadContent>
@* Title *@
@if (!string.IsNullOrWhiteSpace(Meta.Current.Title))
{
<title>@Meta.Current.Title</title>
<meta name="twitter:title" content="@(Meta.Current.OgTitle ?? Meta.Current.Title)" />
<meta property="og:title" content="@(Meta.Current.OgTitle ?? Meta.Current.Title)" />
}
@* Description *@
@if (!string.IsNullOrWhiteSpace(Meta.Current.Description))
{
<meta name="description" content="@Meta.Current.Description" />
<meta name="twitter:description" content="@(Meta.Current.OgDescription ?? Meta.Current.Description)" />
<meta property="og:description" content="@(Meta.Current.OgDescription ?? Meta.Current.Description)" />
}
@* Canonical + URL *@
@if (!string.IsNullOrWhiteSpace(Meta.Current.CanonicalUrl))
{
<link rel="canonical" href="@Meta.Current.CanonicalUrl" />
<meta property="og:url" content="@(Meta.Current.OgUrl ?? Meta.Current.CanonicalUrl)" />
}
@* Images & types *@
@if (!string.IsNullOrWhiteSpace(Meta.Current.OgImage))
{
<meta property="og:image" content="@Meta.Current.OgImage" />
<meta name="twitter:card" content="@Meta.Current.TwitterCard" />
<meta name="twitter:image" content="@Meta.Current.OgImage" />
}
@if (!string.IsNullOrWhiteSpace(Meta.Current.OgType))
{
<meta property="og:type" content="@Meta.Current.OgType" />
}
</HeadContent>
@code {
protected override void OnInitialized() => Meta.Changed += StateHasChanged;
public void Dispose() => Meta.Changed -= StateHasChanged;
}
Place this once under your main layout (or in App.razor
). Avoid <PageTitle>
if you let the renderer output <title>
to prevent duplicates.
<!-- MainLayout.razor -->
<MetaHeadRenderer />
@Body
Step 4 – Use it from pages (drive by route params)
Product details: /product/{id:int}/{slug?}
@page "/product/{id:int}/{slug?}"
@inject MetaService Meta
@inject NavigationManager Nav
@inject IProductsRepository Products
@code {
[Parameter] public int id { get; set; }
[Parameter] public string? slug { get; set; }
private Product? product;
protected override async Task OnParametersSetAsync()
{
product = await Products.ByIdAsync(id);
if (product is null) return;
var title = $"{product.Name} — {product.Brand} | MyStore";
var desc = Truncate($"Buy {product.Name} from {product.Brand}. {product.ShortDescription}", 155);
var url = Nav.Uri;
Meta.Set(new MetaModel(
Title: title,
Description: desc,
CanonicalUrl: url,
OgTitle: title,
OgDescription: desc,
OgImage: product.SocialImageUrl,
OgType: "product",
OgUrl: url,
TwitterCard: string.IsNullOrWhiteSpace(product.SocialImageUrl) ? "summary" : "summary_large_image"));
}
public void Dispose() => Meta.Reset();
static string Truncate(string value, int max) =>
value.Length <= max ? value : value[..max].TrimEnd() + "…";
}
<h1>@product?.Name</h1>
<!-- rest of the page -->
Blog post: /blog/{yyyy}/{mm}/{slug}
@page "/blog/{year:int}/{month:int}/{slug}"
@inject MetaService Meta
@inject NavigationManager Nav
@inject IPostsRepository Posts
@code {
[Parameter] public int year { get; set; }
[Parameter] public int month { get; set; }
[Parameter] public string slug { get; set; } = default!;
private Post? post;
protected override async Task OnParametersSetAsync()
{
post = await Posts.BySlugAsync(year, month, slug);
if (post is null) return;
var url = Nav.Uri;
Meta.Set(new MetaModel(
Title: $"{post.Title} — MyBlog",
Description: MakeExcerpt(post.PlainText, 155),
CanonicalUrl: url,
OgImage: post.SocialImageUrl,
OgUrl: url));
}
public void Dispose() => Meta.Reset();
static string MakeExcerpt(string text, int max)
=> (text ?? string.Empty).Replace("\n", " ").Replace("\r", " ")
.Trim() is var t && t.Length > max ? t[..max] + "…" : t;
}
Search page: /search?q=security&page=2
Leverage query binding with [SupplyParameterFromQuery]
:
@page "/search"
@inject MetaService Meta
@inject NavigationManager Nav
@code {
[SupplyParameterFromQuery] public string? q { get; set; }
[SupplyParameterFromQuery] public int page { get; set; } = 1;
protected override void OnParametersSet()
{
var title = string.IsNullOrWhiteSpace(q)
? "Search — MyApp"
: $"Search: {q} — Page {page} | MyApp";
Meta.Set(new MetaModel(
Title: title,
Description: string.IsNullOrWhiteSpace(q)
? "Search articles, components, and docs."
: $"Results for '{q}'. Page {page}.",
CanonicalUrl: Nav.Uri,
OgType: "website"));
}
public void Dispose() => Meta.Reset();
}
Step 5 – OpenGraph & Twitter checklists
To get rich cards on Slack/Twitter/LinkedIn you’ll typically want:
og:title
,og:description
,og:image
,og:url
,og:type
(article
,product
,website
, etc.)twitter:card
(summary
orsummary_large_image
) andtwitter:image
- Optional:
article:published_time
,article:tag
for posts;product:price:amount
/currency
for products
Our MetaHeadRenderer
already covers the common ones. Add extras as your domain needs.
Tip: Keep descriptions under ~160 chars and titles under ~60 chars so search snippets don’t truncate awkwardly.
Step 6 – Canonical URLs, robots, and duplicates
- Use
<link rel="canonical">
to point to the preferred URL (we feed itNav.Uri
). - If a page can be filtered/sorted, consider adding
noindex
for low‑value combinations:
<HeadContent>
@if (IsNoIndex)
{
<meta name="robots" content="noindex,follow" />
}
</HeadContent>
- Avoid rendering two
<title>
tags. If you use the service to output<title>
, remove<PageTitle>
on that page.
Step 7 – SSR/prerendering notes (.NET 8 Blazor Web App)
- With SSR, head tags from
HeadOutlet
are emitted on the initial prerender, so crawlers see the correct tags. - On client‑side navigation,
HeadOutlet
diffs the head and replaces changed tags. By scoping everything to a single renderer, you avoid stale leftovers. - Keep
MetaService
scoped (default per circuit). Don’t make it singleton, or tags from one user can leak to another.
Step 8 – Test your tags quickly
- Browser devtools → Elements →
<head>
: verify dynamic updates on navigation. - View‑source vs live DOM: SSR shows tags in view‑source; client updates are only in the live DOM (normal for SPAs).
- Use a link preview validator (Twitter/LinkedIn/FB) to see which card they would render for a URL.
Bonus – A minimal integration test (bUnit)
You can assert that your component calls Meta.Set(...)
with expected values. Example with bUnit and a stubbed MetaService
:
[Fact]
public void BlogPage_Pushes_Meta_From_Post()
{
using var ctx = new TestContext();
var meta = new MetaService(new FakeNav("https://example.test/"));
ctx.Services.AddSingleton(meta);
ctx.Services.AddSingleton<IPostsRepository>(new FakePostsRepository());
var cut = ctx.RenderComponent<BlogPost>(p => p
.Add(x => x.year, 2025)
.Add(x => x.month, 10)
.Add(x => x.slug, "dynamic-headoutlet"));
Assert.Equal("Dynamic HeadOutlet — MyBlog", meta.Current.Title);
Assert.Contains("Dynamic", meta.Current.Description);
}
Common pitfalls (and quick fixes)
- Two sources of truth for
<title>
: pick one (service orPageTitle
). - Forgetting
HeadOutlet
: if it’s missing, your<HeadContent>
won’t render. - Very long descriptions: keep them crisp to avoid ugly truncation in snippets and cards.
- User‑controlled values: sanitize anything derived from a slug/name to avoid breaking attributes.
FAQ: Dynamic head management in Blazor
HeadContent
works? No. For a couple of pages, inline HeadContent
is fine. The service shines when you want one place to define how tags map from data and keep pages clean.
With prerendering/SSR they will. If you only ship a pure CSR app without prerendering, some crawlers may rely on the live DOM (works for major platforms), but SSR is safest for SEO.
Render them from a single component (MetaHeadRenderer
). HeadOutlet
will diff and replace. Don’t sprinkle multiple HeadContent
blocks that output the same name
/property
pairs.
MetaHeadRenderer
? In the root layout that all pages use. That guarantees a single source of head truth.
<script type="application/ld+json">
)? Yes – place it inside HeadContent
in the renderer when the page type warrants it (e.g., BlogPosting
, Product
).
Compute localized title/description before calling Meta.Set(...)
. If your routes include culture (e.g., /en/blog/...
), include og:locale
and og:locale:alternate
as needed.
Conclusion: SEO that stays in sync with your UI
You don’t need a framework rewrite to fix snippets and social cards. Add HeadOutlet
, render a tiny head renderer once, and push tags from pages based on route parameters. It’s predictable, testable, and removes copy‑paste head markup.
Give it a try on one high‑traffic page (product, blog, search), measure the snippet’s CTR, and then roll it out.
What’s the first page in your app that would benefit from dynamic tags – and which route params will you map to <title>
and OG? Drop ideas in the comments; I’ll suggest field mappings.