Retour aux articles

SEO et Blazor : les 7 erreurs que j'ai trouvées sur mon propre site (et comment je les ai corrigées)

SEO et Blazor : les 7 erreurs que j'ai trouvées sur mon propre site (et comment je les ai corrigées)

« Blazor, c'est nul pour le SEO. » Je l'ai entendu souvent, et pendant longtemps c'était un peu vrai : une application Blazor WebAssembly envoie une page presque vide au navigateur, puis construit tout en JavaScript... enfin, en .NET. Google sait exécuter du JavaScript, mais il n'aime pas trop attendre.

Depuis .NET 8, ce n'est plus le cas : avec le rendu statique (SSR), le serveur envoie du vrai HTML, complet, dès la première requête. Mon site est entièrement rendu comme ça (je l'ai raconté dans un article précédent).

Alors problème réglé ? Pas tout à fait. Quand j'ai pris le temps d'auditer mon site sérieusement, j'ai trouvé une belle série d'erreurs. Rien de spectaculaire, juste des détails qui, mis bout à bout, empêchent Google de bien comprendre un site. Les voici, avec le code pour les corriger.

TL;DR

  • Le rendu statique règle le problème de fond : Google reçoit du HTML.
  • Blazor n'affiche que le dernier <HeadContent> : un seul composant doit gérer tout le <head>.
  • Un seul domaine (www ou pas, mais un seul), une URL canonique absolue, sans query string.
  • Sur un site bilingue : des hreflang sur chaque page, avec x-default.
  • De vrais 404 : NavigationManager.NotFound() et UseStatusCodePagesWithReExecute, pas une redirection.
  • Des données structurées (JSON-LD) et un sitemap généré à partir de la base.

Avant tout : vérifier ce que Google reçoit vraiment

Le premier réflexe, avant de corriger quoi que ce soit : regarder la page telle qu'elle sort du serveur, sans navigateur.

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

Si votre titre, votre description et votre contenu apparaissent, c'est gagné pour l'essentiel. Si vous ne voyez qu'un <div id="app">Loading...</div>, c'est que la page est rendue côté navigateur (WebAssembly sans prérendu) : c'est le sujet à traiter en premier, avant tout ce qui suit.

Chez moi, le contenu était bien là. Les problèmes étaient ailleurs.

Erreur n°1 : des balises <head> qui s'écrasent

Pour ajouter des balises dans le <head>, Blazor propose <HeadContent>. Simple. Sauf qu'il y a un piège, écrit en petit dans la documentation : si plusieurs <HeadContent> sont rendus, seul le dernier gagne.

Donc si votre layout ajoute une description par défaut, que votre page ajoute ses propres balises Open Graph, et qu'un composant ajoute son JSON-LD... vous ne savez plus vraiment ce qui arrive dans la page.

Ma solution : un seul composant, SeoHead, qui génère tout le <head> de la page, et un seul SeoHead par page. Voici une version simplifiée :

@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; }

    // ...
}

Et dans une page, une seule ligne (ou presque) :

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

<PageTitle> reste à part : c'est un composant différent, il n'a pas ce problème.

Erreur n°2 : des redirections en cascade

Mon site répond sur pierrehenri.fr et sur www.pierrehenri.fr. Pour un humain, c'est le même site. Pour Google, ce sont deux sites avec exactement le même contenu : il doit choisir lequel afficher, et partager le « crédit » des liens entre les deux.

La règle est simple : choisir un domaine et rediriger l'autre en 301 (redirection permanente). Ça, je l'avais fait. Mais ma règle gardait le protocole d'origine : http://pierrehenri.fr partait vers http://www.pierrehenri.fr, puis ASP.NET Core redirigeait vers https://www.pierrehenri.fr. Plusieurs sauts au lieu d'un, et autant de temps perdu pour le visiteur comme pour Google.

Mon site est hébergé sous IIS, donc la correction se passe dans le web.config : rediriger directement vers la bonne URL, en https.

<rewrite>
    <rules>
        <rule name="Redirect to www" stopProcessing="true">
            <match url="(.*)" />
            <conditions>
                <add input="{HTTP_HOST}" pattern="^pierrehenri\.fr$" />
            </conditions>
            <!-- Directement en https : une seule redirection au lieu de plusieurs -->
            <action type="Redirect" redirectType="Permanent" url="https://www.pierrehenri.fr/{R:1}" />
        </rule>
    </rules>
</rewrite>

Autre surprise : UseHttpsRedirection() d'ASP.NET Core redirige par défaut en 307, une redirection temporaire. Pour Google, ça veut dire « l'adresse en http reste la vraie ». Une ligne suffit :

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

Erreur n°3 : une URL canonique approximative

La balise canonical dit à Google : « voici l'adresse officielle de cette page ». Utile quand la même page est accessible par plusieurs URL, par exemple avec des filtres : /fr/blog?tag=Blazor, /fr/blog?page=2...

Chez moi, elle était construite à partir de l'URL de la requête. Donc elle variait selon le domaine utilisé, et gardait la query string. Exactement l'inverse de son rôle. La version corrigée part d'une adresse de référence dans la configuration (SiteUrl) et enlève tout ce qui suit le ? :

// URL canonique sans la query string, sur le domaine de référence
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('/')}";
}

Le même ToAbsolute sert pour l'image Open Graph. Chez moi, twitter:image contenait un chemin relatif (/images/blog/...) : LinkedIn et X n'en font rien, et le partage s'affichait sans image.

Erreur n°4 : des descriptions trop longues

Google coupe la description vers 160 caractères. Certaines de mes pages en avaient 300 : la fin disparaissait au milieu d'une phrase, avec des points de suspension mal placés.

Plutôt que de compter à la main à chaque fois, le composant coupe proprement, sur un mot :

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(',', ';', ':', '.') + "…";
    }
}

C'est un filet de sécurité, pas une excuse : une description écrite pour tenir en 160 caractères sera toujours meilleure. J'ai d'ailleurs ajouté un compteur dans l'éditeur d'articles de mon back-office.

Erreur n°5 : un site bilingue que Google ne voyait pas comme tel

Mon site existe en français (/fr/...) et en anglais (/en/...). Sans indication, Google ne sait pas que /fr/projets et /en/projects sont la même page dans deux langues. Il risque d'afficher la version française à un Anglais, ou de considérer les deux comme du contenu en double.

C'est le rôle des balises hreflang :

<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" />

Deux règles à connaître :

  • chaque version doit pointer vers toutes les autres, y compris elle-même ;
  • x-default indique la page à proposer quand aucune langue ne correspond.

Les écrire à la main sur chaque page ? Non merci. J'avais déjà une méthode qui traduit un chemin d'une langue à l'autre (pour le bouton de changement de langue) : SeoHead s'en sert pour les calculer automatiquement.

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])));
    }
}

Le paramètre Alternates ne sert que dans un cas : quand le slug change d'une langue à l'autre, comme pour les articles de blog. Et si un article n'existe qu'en français, il n'a tout simplement pas de hreflang. Mieux vaut aucune balise qu'une balise qui pointe vers une page qui n'existe pas.

Erreur n°6 : des pages 404 qui n'en étaient pas

Celle-là, je ne l'aurais jamais trouvée sans regarder les codes HTTP. Quand on demandait une page inexistante, mon site faisait ceci :

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

Ça a l'air raisonnable. Sauf que du point de vue de Google, ça donne :

  1. /fr/blog/article-supprime → 302, redirection vers /not-found
  2. /not-found → 200, tout va bien !

Une page d'erreur qui répond « tout va bien », Google appelle ça une soft 404. Il la signale dans la Search Console, et il peut garder de vieilles URL dans son index bien trop longtemps.

La correction : ré-exécuter la page d'erreur au lieu de rediriger. L'URL ne change pas, le code reste 404, et le visiteur voit quand même une jolie page.

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

Reste le cas où la route existe, mais pas le contenu : /fr/blog/{slug} avec un slug qui ne correspond à rien. Avant, la page s'affichait vide... en 200. Depuis .NET 10, il y a une méthode faite pour ça :

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

if (post is null)
{
    // Vrai code 404 (et page NotFound) plutôt qu'une page vide en 200
    _navigationManager.NotFound();
    return;
}

Pour vérifier, curl encore :

curl -s -o /dev/null -w "%{http_code}" https://www.pierrehenri.fr/fr/blog/nexiste-pas
# 404

Erreur n°7 : aucune donnée structurée

Les données structurées (JSON-LD, au format schema.org) décrivent le contenu à Google : « ceci est un article, écrit par telle personne, publié tel jour ». En échange, il peut afficher des résultats plus riches : la date, un fil d'Ariane à la place de l'URL, ou une offre dans Google Jobs.

Je les génère avec une petite classe statique et 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)
    });

Un dictionnaire plutôt que des classes, parce que les clés commencent par @ et que ça reste très lisible. Bonne nouvelle au passage : par défaut, System.Text.Json échappe les caractères < et >. On peut donc insérer le résultat dans une balise <script> sans risque, même si un titre contient </script>.

Le résultat se vérifie avec le test des résultats enrichis de Google.

Bonus : le sitemap

Un sitemap liste les pages de votre site pour aider Google à toutes les trouver. Le mien est un simple contrôleur qui lit la base (articles publiés, offres d'emploi actives) et écrit le XML avec XmlWriter. Il ajoute aussi les hreflang de chaque page, et une date lastmod... seulement quand je connais la vraie date :

// lastmod seulement quand on connaît la vraie date :
// une date « du jour » permanente est ignorée par Google
if (page.LastMod is { } lastMod)
{
    writer.WriteElementString("lastmod", lastMod.ToString("yyyy-MM-dd"));
}

On le déclare ensuite dans robots.txt et dans la Search Console.

Ce que je retiens

  • Blazor n'est plus un problème pour le SEO, à condition de rendre les pages côté serveur. Le reste, c'est du SEO classique, comme sur n'importe quel site.
  • Centraliser le <head> dans un seul composant, c'est ce qui a le plus simplifié les choses. Une page n'a plus qu'à donner son titre, sa description et son image.
  • Regarder les codes HTTP, pas seulement l'écran. Mes faux 404 s'affichaient parfaitement bien.
  • Un audit Lighthouse ne suffit pas. Son score SEO vérifie qu'il y a un titre et une description, pas que les hreflang sont justes ou que les 404 en sont vraiment.

Prochaine étape pour moi : laisser passer quelques semaines et regarder dans la Search Console ce que tout ça a vraiment changé. Et vous, vous avez déjà fait un curl sur vos propres pages ?

Commentaires (0)

Aucun commentaire pour le moment. Soyez le premier à commenter !

Laisser un commentaire

Votre e-mail ne sera pas publié.
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.