Back to articles

SEO and Blazor: 7 mistakes I found on my own site (and how I fixed them)

SEO and Blazor: 7 mistakes I found on my own site (and how I fixed them)

"Blazor is bad for SEO." I've heard it a lot, and for a long time it was a bit true: a Blazor WebAssembly app sends an almost empty page to the browser, then builds everything in JavaScript... well, in .NET. Google can run JavaScript, but it doesn't like waiting.

Since .NET 8, that's no longer the case: with static server-side rendering (SSR), the server sends real, complete HTML on the very first request. My whole site is rendered this way (I told the story in a previous article).

So, problem solved? Not quite. When I took the time to properly audit my site, I found a nice collection of mistakes. Nothing spectacular, just details that, added together, stop Google from understanding a site well. Here they are, with the code to fix them.

TL;DR

  • Static rendering solves the core problem: Google gets HTML.
  • Blazor only renders the last <HeadContent>: a single component should handle the whole <head>.
  • One domain (www or not, but only one), an absolute canonical URL, without the query string.
  • On a bilingual site: hreflang tags on every page, with x-default.
  • Real 404s: NavigationManager.NotFound() and UseStatusCodePagesWithReExecute, not a redirect.
  • Structured data (JSON-LD) and a sitemap generated from the database.

First things first: check what Google actually receives

The first thing to do, before fixing anything: look at the page as it leaves the server, without a browser.

curl -s https://www.pierrehenri.fr/en/blog | grep -E "<title>|description|canonical"

If your title, description and content show up, you've won most of the battle. If all you see is <div id="app">Loading...</div>, the page is rendered in the browser (WebAssembly without prerendering): that's the issue to tackle first, before anything below.

On my site, the content was there. The problems were elsewhere.

Mistake #1: <head> tags overwriting each other

To add tags to the <head>, Blazor gives you <HeadContent>. Easy. Except there's a catch, written in small print in the docs: if several <HeadContent> are rendered, only the last one wins.

So if your layout adds a default description, your page adds its own Open Graph tags, and a component adds its JSON-LD... you no longer really know what ends up in the page.

My solution: a single component, SeoHead, that generates the whole <head> of the page, and only one SeoHead per page. Here's a simplified version:

@inject NavigationManager NavigationManager
@inject IConfiguration Configuration

<HeadContent>
    <meta name="description" content="@MetaDescription" />
    <link rel="canonical" href="@CanonicalUrl" />

    <meta property="og:type" content="@OgType" />
    <meta property="og:url" content="@CanonicalUrl" />
    <meta property="og:title" content="@Title" />
    <meta property="og:description" content="@MetaDescription" />
    <meta property="og:image" content="@AbsoluteImageUrl" />

    <meta name="robots" content="@RobotsContent" />

    @foreach (var json in JsonLd ?? [])
    {
        @((MarkupString)$"<script type=\"application/ld+json\">{json}</script>")
    }
</HeadContent>

@code {
    [Parameter, EditorRequired] public string Title { get; set; } = "";
    [Parameter, EditorRequired] public string Description { get; set; } = "";
    [Parameter] public string ImageUrl { get; set; } = "";
    [Parameter] public string OgType { get; set; } = "website";
    [Parameter] public string RobotsContent { get; set; } = "index, follow";
    [Parameter] public IEnumerable<string>? JsonLd { get; set; }

    // ...
}

And in a page, a single line (or almost):

<PageTitle>@post.Title | Pierre-Henri's blog</PageTitle>
<SeoHead Title="@post.Title"
         Description="@_description"
         OgType="article"
         ImageUrl="@post.FeaturedImage"
         JsonLd="@_jsonLd" />

<PageTitle> stays separate: it's a different component and doesn't have this problem.

Mistake #2: redirect chains

My site answers on pierrehenri.fr and on www.pierrehenri.fr. For a human, it's the same site. For Google, it's two sites with exactly the same content: it has to pick which one to show, and splits the "credit" from links between the two.

The rule is simple: pick one domain and redirect the other with a 301 (permanent redirect). I had done that. But my rule kept the original protocol: http://pierrehenri.fr went to http://www.pierrehenri.fr, then ASP.NET Core redirected to https://www.pierrehenri.fr. Several hops instead of one, and time wasted for visitors and for Google alike.

My site is hosted on IIS, so the fix goes in web.config: redirect straight to the right URL, over https.

<rewrite>
    <rules>
        <rule name="Redirect to www" stopProcessing="true">
            <match url="(.*)" />
            <conditions>
                <add input="{HTTP_HOST}" pattern="^pierrehenri\.fr$" />
            </conditions>
            <!-- Straight to https: one redirect instead of several -->
            <action type="Redirect" redirectType="Permanent" url="https://www.pierrehenri.fr/{R:1}" />
        </rule>
    </rules>
</rewrite>

Another surprise: ASP.NET Core's UseHttpsRedirection() uses a 307 by default, a temporary redirect. To Google, that means "the http address is still the real one". One line is enough:

builder.Services.AddHttpsRedirection(options =>
    options.RedirectStatusCode = StatusCodes.Status308PermanentRedirect);

Mistake #3: a sloppy canonical URL

The canonical tag tells Google: "this is the official address of this page". Useful when the same page can be reached through several URLs, for example with filters: /en/blog?tag=Blazor, /en/blog?page=2...

On my site, it was simply the URL of the request. So it changed depending on the domain used, and kept the query string. The exact opposite of its purpose. The fixed version starts from a reference address in the configuration (SiteUrl) and drops everything after the ?:

// Canonical URL without the query string, on the reference domain
private string CanonicalUrl => ToAbsolute(new Uri(NavigationManager.Uri).AbsolutePath);

private string ToAbsolute(string url)
{
    if (url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
    {
        return url;
    }

    var siteUrl = (Configuration["SiteUrl"] ?? NavigationManager.BaseUri).TrimEnd('/');
    return $"{siteUrl}/{url.TrimStart('/')}";
}

The same ToAbsolute is used for the Open Graph image. On my site, twitter:image contained a relative path (/images/blog/...): LinkedIn and X can't do anything with it, and shares showed up without a picture.

Mistake #4: descriptions that were too long

Google cuts the description at around 160 characters. Some of my pages had 300: the end vanished in the middle of a sentence.

Rather than counting by hand every time, the component cuts cleanly, on a word:

private const int MaxDescriptionLength = 160;

private string MetaDescription
{
    get
    {
        if (Description.Length <= MaxDescriptionLength)
        {
            return Description;
        }

        var cut = Description.LastIndexOf(' ', MaxDescriptionLength - 1);
        return Description[..(cut > 0 ? cut : MaxDescriptionLength - 1)].TrimEnd(',', ';', ':', '.') + "…";
    }
}

It's a safety net, not an excuse: a description written to fit in 160 characters will always be better. I also added a counter to the post editor in my back office.

Mistake #5: a bilingual site Google didn't see as one

My site exists in French (/fr/...) and English (/en/...). Without any hint, Google doesn't know that /fr/projets and /en/projects are the same page in two languages. It may show the French version to an English speaker, or treat both as duplicate content.

That's what hreflang tags are for:

<link rel="alternate" hreflang="fr" href="https://www.pierrehenri.fr/fr/projets" />
<link rel="alternate" hreflang="en" href="https://www.pierrehenri.fr/en/projects" />
<link rel="alternate" hreflang="x-default" href="https://www.pierrehenri.fr/fr/projets" />

Two rules to know:

  • each version must point to all the others, including itself;
  • x-default tells Google which page to offer when no language matches.

Write them by hand on every page? No thanks. I already had a method that translates a path from one language to another (for the language switch button): SeoHead uses it to compute them automatically.

protected override void OnParametersSet()
{
    var path = new Uri(NavigationManager.Uri).AbsolutePath;
    var alternates = Alternates
        ?? Constants.SupportedLanguages.ToDictionary(l => l, l => LanguageService.TranslatePath(path, l));

    _alternates = [];
    if (alternates.Count > 1)
    {
        _alternates.AddRange(alternates.Select(a => (a.Key, ToAbsolute(a.Value))));
        _alternates.Add(("x-default", ToAbsolute(alternates[Constants.DefaultLanguage])));
    }
}

The Alternates parameter is only needed in one case: when the slug changes from one language to the other, as with blog posts. And if a post only exists in French, it simply has no hreflang. Better no tag than a tag pointing to a page that doesn't exist.

Mistake #6: 404 pages that weren't 404s

I would never have found this one without looking at HTTP status codes. When someone requested a page that didn't exist, my site did this:

app.UseStatusCodePages(async context =>
{
    if (context.HttpContext.Response.StatusCode == 404)
    {
        context.HttpContext.Response.Redirect("/not-found");
    }
});

Looks reasonable. Except that from Google's point of view, you get:

  1. /en/blog/deleted-post → 302, redirect to /not-found
  2. /not-found → 200, all good!

An error page that answers "all good" is what Google calls a soft 404. It flags it in Search Console, and it may keep old URLs in its index for far too long.

The fix: re-execute the error page instead of redirecting. The URL doesn't change, the status stays 404, and visitors still see a nice page.

app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);

There's still the case where the route exists but the content doesn't: /en/blog/{slug} with a slug that matches nothing. Before, the page rendered empty... with a 200. Since .NET 10, there's a method made for this:

post = await _blogService.GetPostBySlug(slug, language);

if (post is null)
{
    // Real 404 status (and NotFound page) rather than an empty page with a 200
    _navigationManager.NotFound();
    return;
}

To check, curl again:

curl -s -o /dev/null -w "%{http_code}" https://www.pierrehenri.fr/en/blog/does-not-exist
# 404

Mistake #7: no structured data

Structured data (JSON-LD, using schema.org) describes your content to Google: "this is an article, written by this person, published on this day". In return, it can show richer results: the date, a breadcrumb instead of the URL, or a job offer in Google Jobs.

I generate it with a small static class and System.Text.Json:

public static string BlogPosting(BlogPostDto post, string url, string imageUrl, string description, string siteUrl) =>
    JsonSerializer.Serialize(new Dictionary<string, object?>
    {
        ["@context"] = "https://schema.org",
        ["@type"] = "BlogPosting",
        ["headline"] = post.Title,
        ["description"] = description,
        ["image"] = imageUrl,
        ["url"] = url,
        ["inLanguage"] = post.LanguageCode,
        ["datePublished"] = (post.PublishedAt ?? post.CreatedAt).ToString("o"),
        ["dateModified"] = post.ModifiedAt().ToString("o"),
        ["author"] = PersonNode(siteUrl, post.LanguageCode)
    });

A dictionary rather than classes, because the keys start with @ and it stays very readable. Good news along the way: by default, System.Text.Json escapes the < and > characters. So you can safely put the result inside a <script> tag, even if a title contains </script>.

You can check the result with Google's Rich Results Test.

Bonus: the sitemap

A sitemap lists your site's pages to help Google find them all. Mine is a simple controller that reads the database (published posts, active job offers) and writes the XML with XmlWriter. It also adds the hreflang of each page, and a lastmod date... only when I know the real date:

// lastmod only when we know the real date:
// a permanent "today" date is ignored by Google
if (page.LastMod is { } lastMod)
{
    writer.WriteElementString("lastmod", lastMod.ToString("yyyy-MM-dd"));
}

Then you declare it in robots.txt and in Search Console.

What I take away from this

  • Blazor is no longer an SEO problem, as long as pages are rendered on the server. The rest is classic SEO, like on any other site.
  • Centralizing the <head> in a single component is what simplified things the most. A page only has to give its title, description and image.
  • Look at HTTP status codes, not just the screen. My fake 404s looked perfectly fine.
  • A Lighthouse audit isn't enough. Its SEO score checks that there's a title and a description, not that your hreflang tags are right or that your 404s really are 404s.

Next step for me: let a few weeks go by and see in Search Console what all this actually changed. And you, have you ever run curl on your own pages?

Comments (0)

No comments yet. Be the first to comment!

Leave a comment

Your email will not be published.
An unhandled error has occurred. Reload 🗙

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please reload the page.