Dynamic Title and SEO tags with HeadOutlet in Blazor

Dynamic Title & SEO meta in Blazor with HeadOutlet & SEO meta in Blazor with HeadOutlet

Drive title, description, and OpenGraph from Blazor route params with HeadOutlet. Tiny service, clean API, share‑ready cards.

.NET Development·By amarozka · October 3, 2025

Dynamic Title & SEO meta in Blazor with HeadOutlet & SEO meta in Blazor with HeadOutlet

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 single MetaHeadRenderer at the root, and call Meta.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 or summary_large_image) and twitter: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 it Nav.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 or PageTitle).
  • 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

Do I need the service if 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.

Will crawlers see the tags in an interactive (CSR) app?

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.

How do I avoid duplicate meta tags on navigation?

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.

Where should I place MetaHeadRenderer?

In the root layout that all pages use. That guarantees a single source of head truth.

Can I add structured data (<script type="application/ld+json">)?

Yes – place it inside HeadContent in the renderer when the page type warrants it (e.g., BlogPosting, Product).

What about localization?

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.

Leave a Reply

Your email address will not be published. Required fields are marked *