Mon site est fait en Blazor. Je l'aime bien, mon site. Mais un jour j'ai lancé Lighthouse sur mobile, et la sentence est tombée : 67. Un score orange, sur un site dont le but est justement de montrer que je sais faire du .NET... pas terrible.
Pourtant le site ne fait rien d'extraordinaire : un blog, un CV, quelques pages projets, un job board et un formulaire de contact. Rien qui justifie une application temps réel.
Le coupable ? Le mode Interactive Server, activé un peu partout « parce que c'est pratique ». Dans cet article, je vous montre comment j'ai basculé toutes les pages publiques en rendu statique (SSR), ce que ça change dans le code, et les quelques pièges dans lesquels je suis tombé.
TL;DR
- Un site vitrine ou un blog n'a pas besoin d'un circuit SignalR par visiteur : le rendu statique suffit.
- Les filtres passent en paramètres d'URL, les formulaires en formulaires statiques (
FormName+[SupplyParameterFromForm]).- Tout ce qui est dans
OnAfterRenderAsyncne s'exécute plus : à vérifier en premier !- Résultat en production sur mobile : performance 67-74 → 96-100, accessibilité 84-89 → 100, LCP 5-7,6 s → 1,5-2,7 s.
Les 3 modes de rendu de Blazor en 2 minutes
Depuis .NET 8, une application Blazor peut mélanger plusieurs modes de rendu, page par page (voire composant par composant) :
| Mode | Où tourne le code ? | Ce que reçoit le navigateur | Idéal pour |
|---|---|---|---|
| Statique (SSR) | Sur le serveur, une seule fois | Du HTML, point. | Pages de contenu, blog, vitrine |
| Interactive Server | Sur le serveur, en continu | Du HTML + une connexion SignalR permanente | Back-office, tableaux de bord |
| Interactive WebAssembly | Dans le navigateur | Du HTML + le runtime .NET à télécharger | Applications riches, hors ligne |
Le mode statique est celui par défaut. Le mode interactif s'active avec une ligne :
@rendermode InteractiveServer
Et c'est là que le piège se referme : cette ligne est tellement simple à ajouter qu'on finit par la mettre partout. Un @onclick qui ne répond pas ? Hop, @rendermode InteractiveServer, et ça marche.
Sauf qu'en mode Interactive Server, chaque visiteur ouvre une connexion WebSocket avec le serveur, qui garde en mémoire l'état de sa page (le « circuit »). Pour un back-office avec trois utilisateurs, aucun problème. Pour un blog lu par des inconnus (et par les robots de Google), c'est du gâchis : du JavaScript en plus à charger, une connexion à établir avant que la page soit utilisable, et de la mémoire consommée côté serveur.
Mon site est hébergé sur SmarterASP.NET, un hébergement mutualisé où la mémoire est comptée. Raison de plus pour ne pas garder des circuits ouverts pour rien.
Étape 1 : traquer l'interactivité inutile
Premier réflexe, chercher tous les @rendermode du projet et se poser une question simple pour chacun : qu'est-ce qui a vraiment besoin de tourner côté serveur après l'affichage ?
Mon meilleur exemple : le pied de page. Il était en Interactive Server... sur toutes les pages du site. Tout ça pour deux boutons de changement de langue :
@rendermode InteractiveServer
<div class="language-selector">
<button class="btn-href" @onclick="@(() => ChangerLangue("fr"))">🇫🇷 Français</button>
<button class="btn-href" @onclick="@(() => ChangerLangue("en"))">🇬🇧 English</button>
</div>
Un pied de page interactif, c'est un circuit SignalR ouvert sur chaque page, même les plus simples. Alors qu'un changement de langue, c'est... un lien vers la page traduite :
<div class="language-selector">
<a class="btn-href" href="@LanguageUrl("fr")" hreflang="fr" lang="fr" data-enhance-nav="false">🇫🇷 Français</a>
<a class="btn-href" href="@LanguageUrl("en")" hreflang="en" lang="en" data-enhance-nav="false">🇬🇧 English</a>
</div>
LanguageUrl calcule l'URL de la page courante dans l'autre langue (/fr/projets ↔ /en/projects). Le data-enhance-nav="false" force un vrai rechargement, pour que la balise <html lang="..."> soit bien mise à jour.
En faisant le tour, j'ai trouvé la même chose sur le CV et le détail des offres d'emploi : interactifs, alors qu'ils ne faisaient qu'afficher des données. Une ligne à supprimer à chaque fois.
Étape 2 : les filtres deviennent des paramètres d'URL
Le blog et le job board avaient des filtres « en direct » : je tape « Blazor », la liste se met à jour à chaque frappe. Très joli, mais 100 % dépendant du circuit :
@rendermode InteractiveServer
<input type="text" class="form-control" placeholder="🔍 Rechercher par technologie..."
@bind="_searchTechnology" @bind:event="oninput" @onkeyup="FilterOffers" />
<select class="form-select" @bind="_searchContractType" @bind:after="FilterOffers">
<option value="">Tous les types de contrat</option>
<option value="CDI">CDI</option>
...
</select>
En statique, on revient aux bases du web : un formulaire en GET. Les filtres finissent dans l'URL (/fr/dotnet-job-board?tech=blazor&contract=CDI) et Blazor les récupère avec [SupplyParameterFromQuery] :
<form method="get" action="/fr/dotnet-job-board" data-autosubmit>
<input type="search" name="tech" value="@Technology" placeholder="🔍 Rechercher par technologie..." />
<select name="contract">
<option value="">Tous les types de contrat</option>
@foreach (var type in ContractTypes)
{
<option value="@type" selected="@(type == Contract)">@type</option>
}
</select>
<button type="submit" class="visually-hidden">Filtrer</button>
</form>
@code {
[SupplyParameterFromQuery(Name = "tech")] public string? Technology { get; set; }
[SupplyParameterFromQuery(Name = "contract")] public string? Contract { get; set; }
protected override void OnParametersSet()
{
_filteredOffers = [.. _allOffers.Where(offer =>
(string.IsNullOrWhiteSpace(Technology) || offer.Technologies.Any(t => t.Contains(Technology, StringComparison.OrdinalIgnoreCase)))
&& (string.IsNullOrWhiteSpace(Contract) || offer.ContractType == Contract))];
}
}
Pour garder le confort d'une liste déroulante qui filtre toute seule, trois lignes de JavaScript suffisent :
document.addEventListener('change', function (event) {
const form = event.target.closest('form[data-autosubmit]');
if (form && event.target.matches('select')) {
form.requestSubmit();
}
});
Et cerise sur le gâteau, cette version a des avantages que l'ancienne n'avait pas :
- Ça marche sans JavaScript (et donc pour tous les robots).
- Une recherche filtrée est une URL qu'on peut partager ou mettre en favori.
- Les pages filtrées et paginées du blog sont explorables par Google (avec une balise canonical qui pointe vers la page principale, pour éviter le contenu dupliqué).
- Grâce à la navigation améliorée de Blazor (
blazor.web.js), la page n'est pas rechargée entièrement : seul le contenu modifié est remplacé. Visuellement, on ne voit presque pas la différence avec l'ancienne version.
Étape 3 : des formulaires sans circuit
Le formulaire de contact et celui des commentaires du blog étaient interactifs, eux aussi. Bonne nouvelle : EditForm fonctionne très bien en statique. Il faut simplement lui donner un nom, et dire à Blazor où ranger les données postées :
<EditForm Model="@NewComment" OnValidSubmit="SubmitComment" FormName="blog-comment" Enhance>
<DataAnnotationsValidator />
<InputText @bind-Value="NewComment!.AuthorName" class="form-control" />
<ValidationMessage For="@(() => NewComment!.AuthorName)" />
...
<button type="submit" class="btn btn-primary">Publier le commentaire</button>
</EditForm>
@code {
[SupplyParameterFromForm(FormName = "blog-comment")]
private CreateBlogCommentDto? NewComment { get; set; }
protected override async Task OnInitializedAsync()
{
NewComment ??= new();
...
}
}
Trois choses à retenir :
FormNameidentifie le formulaire (il peut y en avoir plusieurs sur la page) et[SupplyParameterFromForm]reçoit les valeurs postées.Enhanceenvoie le formulaire en arrière-plan et ne remplace que ce qui change : pas de rechargement complet, pas de retour en haut de page.- La protection anti-falsification (antiforgery) est ajoutée automatiquement. Rien à faire.
Petit détail qui a son importance : on n'initialise pas la propriété directement (= new();). Lors d'un POST, c'est Blazor qui la renseigne avec les données du formulaire. On la crée seulement si elle est vide, dans OnInitializedAsync. L'analyseur de Blazor vous le rappellera d'ailleurs avec un warning BL0008.
La validation ([Required], [EmailAddress]...) fonctionne exactement comme avant, avec un aller-retour serveur au lieu d'une validation « en direct ». Pour un formulaire de contact, honnêtement, personne ne fait la différence.
Le piège : OnAfterRenderAsync ne s'exécute plus
C'est LE point à vérifier en premier, et je l'ai découvert après coup : le compteur de vues de mes articles ne bougeait plus.
Il était incrémenté ici :
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && post != null)
{
_ = _blogService.IncrementViewCount(post.Id);
}
}
En rendu statique, il n'y a pas de « après le rendu » côté navigateur : le serveur génère le HTML et c'est terminé. OnAfterRenderAsync n'est jamais appelé. Pas d'erreur, pas de warning, juste un compteur figé.
J'ai déplacé le comptage au chargement de la page, en profitant pour ne pas compter les robots (moteurs de recherche, aperçus de liens, Lighthouse...) ni l'envoi d'un commentaire, qui recharge aussi la page :
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
protected override async Task OnInitializedAsync()
{
post = await _blogService.GetPostBySlug(slug, _languageService.CurrentLanguage);
...
if (HttpContext is not null
&& HttpMethods.IsGet(HttpContext.Request.Method)
&& !IsBot(HttpContext.Request.Headers.UserAgent))
{
_ = _blogService.IncrementViewCount(post.Id);
}
}
private static bool IsBot(string? userAgent) =>
string.IsNullOrEmpty(userAgent) ||
Regex.IsMatch(userAgent, "bot|crawl|spider|slurp|lighthouse|headless|preview", RegexOptions.IgnoreCase);
Au passage, le HttpContext est disponible en rendu statique, ce qui n'est pas le cas en mode interactif. C'est bien pratique.
Mon conseil : avant de retirer un @rendermode, faites une recherche sur OnAfterRender et IJSRuntime dans le composant. Tout ce que vous y trouvez devra être déplacé ou remplacé par un peu de JavaScript.
Et le JavaScript qui restait ?
Quelques comportements avaient vraiment besoin du navigateur : le menu mobile, le bandeau de cookies (avec Google Analytics chargé seulement après consentement), la coloration syntaxique du code des articles. Ils étaient éparpillés dans des composants interactifs.
Tout est maintenant dans un seul petit fichier site.js, chargé avec defer. Seule subtilité : avec la navigation améliorée, la page n'est pas rechargée, donc le script doit se relancer quand le contenu change. Blazor émet un événement pour ça :
Blazor.addEventListener('enhancedload', onPageReady);
J'en ai profité pour supprimer le JavaScript de Bootstrap : je n'utilisais que le menu repliable, recodé en une dizaine de lignes.
Et l'administration ?
Le back-office, lui, reste en Interactive Server, et c'est très bien comme ça : il utilise MudBlazor, des boîtes de dialogue, un éditeur Markdown avec aperçu en direct... C'est exactement le cas d'usage de ce mode.
La seule chose que j'ai changée : ne plus charger MudBlazor sur les pages publiques. Son CSS, son JavaScript et la police Roboto étaient chargés sur toutes les pages, pour rien. Dans App.razor :
@if (_isAdmin)
{
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
<link rel="stylesheet" href="@Assets["_content/MudBlazor/MudBlazor.min.css"]" />
}
_isAdmin vérifie simplement si l'URL commence par /admin. Une centaine de Ko économisés sur chaque page publique.
Les petits plus qui font la différence
Le rendu statique a fait le gros du travail, mais tant que j'y étais, j'ai aussi traité ce que Lighthouse signalait encore :
- Un seul fichier CSS au lieu de 9
@importen cascade (chaque@importest une requête de plus, chargée l'une après l'autre). Le fichier est assemblé au démarrage, compressé et mis en cache pour un an. - Une police d'icônes réduite aux icônes réellement utilisées : de 127 Ko à 14 Ko. La police complète reste en secours au cas où.
- Les images en WebP, avec un petit composant
SiteImagequi sert la version.webpsi elle existe et charge les images en différé (sauf l'image principale, chargée en priorité) : de 6,2 Mo à 1,5 Mo d'images. - Côté serveur, le GC « workstation » à la place du GC serveur (
<ServerGarbageCollection>false</ServerGarbageCollection>), bien plus économe en mémoire pour un petit site.
Les résultats
Mesures Lighthouse sur mobile, en production, sur les principales pages du site (accueil, blog, article, CV, contact) :
| Avant | Après | |
|---|---|---|
| Performance | 67 à 74 | 96 à 100 |
| Accessibilité | 84 à 89 | 100 |
| SEO | 100 | 100 |
| LCP (affichage du contenu principal) | 5 à 7,6 s | 1,5 à 2,7 s |
| Circuits SignalR ouverts par les visiteurs | 1 par visiteur | 0 |
Le SEO était déjà à 100 (Lighthouse vérifie surtout les balises), mais Google tient compte de la vitesse réelle : un LCP divisé par trois, ça compte.
Quand garder Interactive Server ?
Je ne dis pas qu'il faut bannir le mode interactif, loin de là. Il reste le bon choix pour :
- un back-office ou un tableau de bord, utilisé par peu de personnes connectées ;
- une page avec beaucoup d'interactions qui dépendent de données serveur (un éditeur, un configurateur...) ;
- du temps réel (notifications, mises à jour en direct).
Pour tout le reste (pages de contenu, blog, vitrine, formulaires simples), le rendu statique est plus rapide, plus léger, mieux référencé, et franchement plus simple à maintenir. Ma règle aujourd'hui : statique par défaut, interactif seulement là où c'est indispensable, et jamais sur un composant partagé comme un en-tête ou un pied de page.
Vous avez un site Blazor qui rame sur Lighthouse ? Faites le test : cherchez vos @rendermode et posez-vous la question pour chacun. Vous pourriez être surpris.
Une question, un retour d'expérience ? N'hésitez pas à me contacter ou à laisser un commentaire !
Pour aller plus loin, le déploiement de ce site sur SmarterASP est expliqué ici :

Commentaires (0)
Aucun commentaire pour le moment. Soyez le premier à commenter !
Laisser un commentaire