---
title: "Blazor Server sous .NET 10 : ce qui change vraiment"
description: "Ce qui change concrètement dans Blazor Server avec .NET 10 : pipeline de rendu, SSR interactif, streaming, SEO. Un retour terrain depuis ekioo.com."
date: 2026-05-04T09:00:00+02:00
tags: ["Blazor", ".NET", "SSR", "SEO", "Architecture"]
---

## Le problème avec les articles sur Blazor

La plupart des articles sur Blazor .NET 10 ressemblent à ça : une liste de nouveautés copiée depuis les release notes, quelques snippets de code sortis de nulle part, et une conclusion enthousiaste du type "Blazor n'a jamais été aussi bon !".

Ce n'est pas ce que tu trouveras ici.

Ekioo.com tourne sur **Blazor Server + .NET 10 en production**. Pas un projet demo, pas un tutorial : un site réel avec du contenu bilingue, un blog, un sitemap, des pages projets, du SEO. Cette article est un compte-rendu de ce que j'ai observé en pratique — ce qui a changé, ce qui a amélioré les choses, et ce qui continue de coûter cher.

---

## Le pipeline de rendu en .NET 10 : ce qui a vraiment bougé

### Avant : trois modes, trois modèles

Jusqu'à .NET 8, Blazor avait trois modes de rendu bien distincts et mutuellement exclusifs :

- **Blazor Server** : le DOM est géré côté serveur, les interactions transitent via SignalR.
- **Blazor WebAssembly** : tout le runtime est téléchargé dans le navigateur, aucun serveur à l'exécution.
- **Blazor SSR (static)** : rendu HTML côté serveur au premier chargement, sans interactivité ensuite.

.NET 8 a introduit les **render modes** — l'idée qu'un même projet peut mélanger ces modes à la granularité du composant. Un composant peut être `@rendermode InteractiveServer` (SignalR), un autre `@rendermode InteractiveWebAssembly`, un autre purement statique. Sur le papier, élégant. En pratique, piégeux.

### .NET 9 : stabilisation, pas révolution

.NET 9 a surtout corrigé les frictions introduites par .NET 8. Le système de render modes est devenu plus prévisible, les erreurs de hydration moins fréquentes, le comportement du streaming SSR plus stable. C'est la version où Blazor SSR est passé de "prometteur mais fragile" à "utilisable sérieusement".

### .NET 10 : le rendu statique devient premier de classe

Ce qui change en .NET 10, c'est que **le rendu statique (Static SSR) devient le mode de référence**. Les templates par défaut génèrent des applications SSR-first. L'interactivité est opt-in, localisée, composant par composant.

Concrètement :

- Les pages par défaut sont rendues en SSR statique — HTML complet envoyé au client, pas de JavaScript requis.
- Les composants qui ont besoin d'interactivité déclarent explicitement leur render mode.
- Le circuit SignalR n'est ouvert que là où c'est nécessaire, pas globalement pour toute l'application.

Pour un site comme ekioo.com — contenu principalement statique, une poignée d'interactions isolées — c'est le modèle qui fait sens. Et c'est le modèle que .NET 10 arrête de traiter comme un cas particulier.

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 460" style="max-width:100%;display:block;margin:2rem auto;border-radius:12px">
  <defs>
    <pattern id="bdn10-dots" width="20" height="20" patternUnits="userSpaceOnUse">
      <circle cx="10" cy="10" r="0.8" fill="#1a2535"/>
    </pattern>
    <filter id="bdn10-glow-g" x="-40%" y="-40%" width="180%" height="180%">
      <feGaussianBlur stdDeviation="7" result="b"/>
      <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
    </filter>
    <filter id="bdn10-glow-p" x="-40%" y="-40%" width="180%" height="180%">
      <feGaussianBlur stdDeviation="6" result="b"/>
      <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
    </filter>
  </defs>
  <rect width="760" height="460" rx="12" fill="#0b0f19"/>
  <rect width="760" height="460" rx="12" fill="url(#bdn10-dots)"/>
  <text x="380" y="36" font-family="'Courier New',Courier,monospace" font-size="11" fill="#2d4060" text-anchor="middle" letter-spacing="4">PIPELINE DE RENDU — BLAZOR .NET 10</text>
  <rect x="20" y="56" width="138" height="60" rx="7" fill="#111827" stroke="#1e3a5f" stroke-width="1.5"/>
  <text x="89" y="80" font-family="'Courier New',Courier,monospace" font-size="9" fill="#3d5a7a" text-anchor="middle" letter-spacing="2">HTTP</text>
  <text x="89" y="100" font-family="'Courier New',Courier,monospace" font-size="10" fill="#5b7fa0" text-anchor="middle">GET /blog/slug</text>
  <line x1="158" y1="86" x2="206" y2="86" stroke="#1e3a5f" stroke-width="1.5"/>
  <polygon points="206,81 214,86 206,91" fill="#1e3a5f"/>
  <rect x="214" y="56" width="148" height="60" rx="7" fill="#111827" stroke="#1e3a5f" stroke-width="1.5"/>
  <text x="288" y="80" font-family="'Courier New',Courier,monospace" font-size="9" fill="#3d5a7a" text-anchor="middle" letter-spacing="2">ROUTER</text>
  <text x="288" y="100" font-family="'Courier New',Courier,monospace" font-size="10" fill="#5b7fa0" text-anchor="middle">composant matché</text>
  <line x1="362" y1="86" x2="368" y2="86" stroke="#5b3fa8" stroke-width="1.5"/>
  <polygon points="368,81 376,86 368,91" fill="#5b3fa8"/>
  <polygon points="376,86 446,50 516,86 446,122" fill="#100d28" stroke="#7c3aed" stroke-width="1.5"/>
  <text x="446" y="81" font-family="'Courier New',Courier,monospace" font-size="9" fill="#a78bfa" text-anchor="middle">@rendermode</text>
  <text x="446" y="97" font-family="'Courier New',Courier,monospace" font-size="9" fill="#a78bfa" text-anchor="middle">déclaré ?</text>
  <text x="537" y="74" font-family="'Courier New',Courier,monospace" font-size="10" fill="#3b82f6" text-anchor="middle">oui</text>
  <line x1="516" y1="86" x2="557" y2="86" stroke="#3b82f6" stroke-width="1.5"/>
  <polygon points="557,81 565,86 557,91" fill="#3b82f6"/>
  <rect x="565" y="44" width="180" height="100" rx="7" fill="#0a1628" stroke="#2563eb" stroke-width="1.5"/>
  <rect x="565" y="44" width="180" height="6" rx="4" fill="#2563eb" opacity="0.7"/>
  <text x="655" y="70" font-family="'Courier New',Courier,monospace" font-size="8" fill="#3b82f6" text-anchor="middle" letter-spacing="2">INTERACTIF (opt-in)</text>
  <text x="655" y="88" font-family="'Courier New',Courier,monospace" font-size="10" fill="#60a5fa" text-anchor="middle">@rendermode</text>
  <text x="655" y="104" font-family="'Courier New',Courier,monospace" font-size="10" fill="#60a5fa" text-anchor="middle">InteractiveServer</text>
  <text x="655" y="120" font-family="'Courier New',Courier,monospace" font-size="9" fill="#1e3a5f" text-anchor="middle">circuit SignalR / composant</text>
  <line x1="446" y1="122" x2="446" y2="184" stroke="#22c55e" stroke-width="2"/>
  <polygon points="441,184 446,192 451,184" fill="#22c55e"/>
  <text x="462" y="148" font-family="'Courier New',Courier,monospace" font-size="10" fill="#22c55e">non</text>
  <text x="462" y="164" font-family="'Courier New',Courier,monospace" font-size="9" fill="#374151">(défaut)</text>
  <rect x="16" y="192" width="200" height="100" rx="7" fill="#0b0f19" stroke="#2d3f55" stroke-width="1" stroke-dasharray="6,3"/>
  <text x="116" y="216" font-family="'Courier New',Courier,monospace" font-size="8" fill="#374151" text-anchor="middle" letter-spacing="2">OPT-IN</text>
  <text x="116" y="234" font-family="'Courier New',Courier,monospace" font-size="8.5" fill="#475569" text-anchor="middle">@attribute [StreamRendering]</text>
  <text x="116" y="253" font-family="'Courier New',Courier,monospace" font-size="9" fill="#374151" text-anchor="middle">flush HTML progressif</text>
  <text x="116" y="272" font-family="'Courier New',Courier,monospace" font-size="8.5" fill="#2d3748" text-anchor="middle">placeholders → contenu</text>
  <line x1="216" y1="242" x2="288" y2="242" stroke="#2d3748" stroke-width="1" stroke-dasharray="5,3"/>
  <polygon points="288,237 296,242 288,247" fill="#2d3748"/>
  <rect x="296" y="192" width="296" height="100" rx="7" fill="#051409" stroke="#16a34a" stroke-width="2" filter="url(#bdn10-glow-g)"/>
  <rect x="296" y="192" width="296" height="6" rx="4" fill="#16a34a" opacity="0.7"/>
  <text x="444" y="218" font-family="'Courier New',Courier,monospace" font-size="8" fill="#4ade80" text-anchor="middle" letter-spacing="3">MODE PAR DÉFAUT</text>
  <text x="444" y="244" font-family="'Courier New',Courier,monospace" font-size="18" fill="#22c55e" text-anchor="middle" font-weight="bold">Static SSR</text>
  <text x="444" y="264" font-family="'Courier New',Courier,monospace" font-size="9.5" fill="#94a3b8" text-anchor="middle">HTML complet · 0 JS · 0 circuit SignalR</text>
  <text x="444" y="281" font-family="'Courier New',Courier,monospace" font-size="8.5" fill="#3d5738" text-anchor="middle">TTFB &lt; 50 ms · CLS = 0</text>
  <line x1="444" y1="292" x2="444" y2="316" stroke="#7c3aed" stroke-width="2"/>
  <polygon points="439,316 444,324 449,316" fill="#7c3aed"/>
  <rect x="60" y="324" width="640" height="94" rx="7" fill="#0e0b1f" stroke="#7c3aed" stroke-width="1.5" filter="url(#bdn10-glow-p)"/>
  <rect x="60" y="324" width="640" height="6" rx="4" fill="#7c3aed" opacity="0.5"/>
  <text x="380" y="350" font-family="'Courier New',Courier,monospace" font-size="9" fill="#7c3aed" text-anchor="middle" letter-spacing="3">RÉPONSE HTTP</text>
  <text x="380" y="374" font-family="'Courier New',Courier,monospace" font-size="13" fill="#c4b5fd" text-anchor="middle">HTML → Navigateur · Googlebot ✓</text>
  <rect x="68" y="384" width="94" height="26" rx="13" fill="#0d1a10"/>
  <text x="115" y="401" font-family="'Courier New',Courier,monospace" font-size="8.5" fill="#4ade80" text-anchor="middle">TTFB &lt; 50 ms</text>
  <rect x="170" y="384" width="78" height="26" rx="13" fill="#0d1a10"/>
  <text x="209" y="401" font-family="'Courier New',Courier,monospace" font-size="8.5" fill="#4ade80" text-anchor="middle">LCP = titre</text>
  <rect x="256" y="384" width="62" height="26" rx="13" fill="#0d1a10"/>
  <text x="287" y="401" font-family="'Courier New',Courier,monospace" font-size="8.5" fill="#4ade80" text-anchor="middle">CLS = 0</text>
  <rect x="326" y="384" width="82" height="26" rx="13" fill="#0d1420"/>
  <text x="367" y="401" font-family="'Courier New',Courier,monospace" font-size="8.5" fill="#60a5fa" text-anchor="middle">0 JS requis</text>
  <rect x="416" y="384" width="82" height="26" rx="13" fill="#0d1420"/>
  <text x="457" y="401" font-family="'Courier New',Courier,monospace" font-size="8.5" fill="#60a5fa" text-anchor="middle">Googlebot ✓</text>
  <rect x="506" y="384" width="186" height="26" rx="13" fill="#130d28"/>
  <text x="599" y="401" font-family="'Courier New',Courier,monospace" font-size="8.5" fill="#a78bfa" text-anchor="middle">SEO natif · 0 hydration</text>
  <text x="744" y="452" font-family="'Courier New',Courier,monospace" font-size="8" fill="#1a2535" text-anchor="end">ekioo.com</text>
</svg>

---

## Blazor SSR interactif : le modèle de composants qui a changé

### Ce que "interactif" veut dire maintenant

Avant .NET 8, "Blazor Server" signifiait que tout le cycle de vie des composants passait par SignalR. `OnInitializedAsync`, `OnParametersSetAsync`, les event handlers — tout voyageait sur le wire. Ça avait un coût : latence, ressources serveur, fragilité réseau.

En .NET 10, le cycle de vie des composants statiques est beaucoup plus simple : ils vivent le temps d'un rendu, sur le serveur, et disparaissent. Pas de state côté serveur, pas de circuit ouvert. La page est rendue, l'HTML est envoyé, c'est terminé.

Quand tu ajoutes `@rendermode InteractiveServer` sur un composant, tu opt-in explicitement dans le modèle SignalR pour ce composant uniquement. Le reste de la page reste statique.

### Les pièges du mélange statique / interactif

Ce modèle hybride est puissant mais demande de la discipline.

**Problème 1 : les paramètres complexes ne passent pas la frontière**

Un composant statique ne peut pas passer un objet C# complexe à un composant interactif. La frontière statique/interactif est sérialisée — tu ne peux passer que des types primitifs ou JSON-sérialisables. Si tu essaies de passer un `IEnumerable<MyComplexType>` depuis un composant statique vers un fils interactif, ça ne compile pas ou ça échoue à l'exécution.

La solution : passer des IDs ou des clés, et laisser le composant interactif aller chercher les données lui-même.

**Problème 2 : les services ne sont pas partagés de la même façon**

En mode interactif, les services `Scoped` sont liés au circuit SignalR — ils survivent à la navigation intra-SPA. En mode statique, ils sont liés à la requête HTTP — ils sont recréés à chaque navigation. Si tu as un service qui maintient un état utilisateur en mémoire, son comportement change radicalement selon le mode de rendu.

Pour ekioo.com, j'ai simplifié : aucun état en mémoire, tout est recalculé depuis les fichiers markdown à chaque requête. C'est un choix délibéré pour éviter cette classe de bugs.

**Problème 3 : le prerendering par défaut**

En mode `InteractiveServer`, Blazor effectue un prerender statique avant d'établir le circuit SignalR. Ça veut dire que `OnInitializedAsync` s'exécute deux fois : une fois côté serveur (rendu statique), une fois quand le circuit s'ouvre (rendu interactif). Si tu fais des appels HTTP dans `OnInitializedAsync`, ils sont doublés.

Le fix : vérifier `!FirstRender` ou utiliser `OnAfterRenderAsync(bool firstRender)` pour les opérations qui ne doivent s'exécuter qu'une fois.

---

## Streaming rendering : comportement réel et cas d'usage

### Ce que le streaming SSR fait vraiment

Le streaming rendering (activé avec `@attribute [StreamRendering]`) permet à Blazor d'envoyer le HTML au fur et à mesure que les composants se rendent, sans attendre que toute la page soit prête.

Concrètement : si un composant charge des données lentes (requête base de données, appel API), Blazor peut envoyer le reste de la page immédiatement et "streamer" ce composant quand ses données sont disponibles.

Du point de vue du navigateur : la page commence à s'afficher très rapidement, avec des placeholders pour les parties en attente, puis le contenu final remplace les placeholders sans rechargement visible.

### Cas d'usage sur ekioo.com

Sur ekioo.com, le blog liste les articles depuis des fichiers markdown sur disque. C'est rapide, pas besoin de streaming. Mais si j'avais une section "posts récents" avec des données venant d'une API tierce (latence variable), le streaming serait pertinent : je pourrais afficher le reste de la page instantanément et laisser cette section se charger à son rythme.

### Pièges à connaître

Le streaming SSR change la façon dont les sélecteurs CSS et JavaScript initialisés au chargement interagissent avec le DOM. Si tu as des scripts qui s'exécutent sur `DOMContentLoaded` et qui cherchent des éléments DOM, ils peuvent s'exécuter avant que les éléments streamés soient disponibles.

Sur ekioo.com, l'animation de particules est initialisée via un script classique — j'ai dû m'assurer qu'elle s'attache au canvas de la page et non à des éléments qui pourraient arriver en streaming plus tard.

Autre point : les outils SEO et les crawlers comprennent de mieux en mieux le streaming (Googlebot supporte le rendu JavaScript depuis longtemps), mais les outils d'audit classiques peuvent voir du contenu incomplet s'ils ne gèrent pas les réponses chunked.

---

## Static SSR et SEO : ce que ça change vraiment

### Le problème de fond : Blazor SPA et Googlebot

Le Blazor WebAssembly classique était un problème SEO sérieux. Googlebot reçoit une coquille HTML vide, attend que le WebAssembly se charge (timeout variable), et indexe un contenu potentiellement incomplet ou retardé. Pas idéal.

Blazor Server évitait partiellement ce problème — le prerender envoyait de l'HTML complet au premier chargement. Mais le temps de rendu côté serveur et l'establishment du circuit SignalR ajoutaient de la latence, et les métriques Core Web Vitals en souffraient.

### Static SSR : HTML complet, zéro JavaScript requis

En Static SSR pur, le serveur génère du HTML complet et l'envoie directement. Pas de JavaScript nécessaire pour voir le contenu. Googlebot reçoit ce que l'utilisateur voit. C'est le comportement d'un site PHP ou d'un générateur de site statique — mais avec la productivité de C# et le modèle de composants Razor.

Pour ekioo.com, voici ce que ça donne en pratique :

**Time to First Byte (TTFB)** : sur un VPS modeste (2 vCPU, 4 GB RAM), les pages statiques servent en **< 50 ms** après un warm-up. Le cold start (démarrage du process dotnet) est la seule latence notable, mais c'est un one-shot.

**Largest Contentful Paint (LCP)** : sans JavaScript bloquant le rendu, le LCP est celui du contenu principal — pas d'un spinner ou d'un squelette vide. Sur les pages blog d'ekioo.com, le LCP est le titre de l'article, visible dès que le HTML arrive.

**Cumulative Layout Shift (CLS)** : le streaming SSR peut introduire du CLS si les placeholders ont des dimensions différentes du contenu final. En Static SSR pur (sans streaming), ce risque n'existe pas : le layout est fixe dès le premier octet.

### Le sitemap comme signal d'autorité

Ekioo.com expose deux sitemaps : `/sitemap.xml` (FR + EN) et `/sitemap-content.xml` (markdown brut). Les dates `lastmod` sont tirées directement du frontmatter YAML des fichiers markdown.

Ce n'est pas une décoration : Googlebot utilise `lastmod` pour prioriser le recrawl. Une date incorrecte (trop ancienne ou trop récente) brouille ce signal. Avec Blazor SSR, je génère ces dates dynamiquement depuis les fichiers sur disque — pas de décalage possible entre le contenu réel et ce que déclare le sitemap.

---

## Un exemple concret : la navigation bilingue sur ekioo.com

Un cas qui illustre bien les contraintes du modèle SSR :

Ekioo.com est bilingue (FR/EN). La détection de langue se fait uniquement depuis l'URL — pas de cookie, pas de header `Accept-Language`, pas de redirection automatique. C'est une décision SEO délibérée : Google recommande des URLs distinctes par langue, pas de redirections basées sur la géolocalisation.

Chaque page Razor déclare deux routes :

```csharp
@page "/blog/{Slug}"
@page "/en/blog/{Slug}"
```

Et un paramètre en cascade transmet la langue à tous les composants enfants :

```csharp
[CascadingParameter] public Lang Lang { get; set; }
```

Ce pattern fonctionne parfaitement en Static SSR. Il n'y a pas d'état de navigation à maintenir entre les pages — chaque URL est une requête indépendante, servie par le bon composant avec la bonne langue.

En Blazor Server classique (mode interactif global), ce type de navigation pouvait créer des inconsistances : la langue pouvait "rester" de la page précédente si le circuit SignalR restait ouvert et que le state n'était pas correctement réinitialisé. En SSR pur, ce problème n'existe pas structurellement.

---

## Verdict : pour qui .NET 10 + Blazor SSR est le bon choix en 2026

**C'est le bon choix si :**

- Tu construis un site majoritairement statique (blog, portfolio, documentation, site corporate) avec de l'interactivité localisée (formulaire de contact, composant de recherche, panier d'achat).
- Tu travailles en C# et tu veux éviter le context-switching entre un backend .NET et un frontend React/Vue/Angular.
- Le SEO est critique — tu veux que les crawlers voient exactement ce que les utilisateurs voient, sans dépendre du rendu JavaScript.
- Tu vises des performances Core Web Vitals solides sans CDN et sans infrastructure complexe.

**Ce n'est pas le bon choix si :**

- Tu construis une application hautement interactive (dashboard temps réel, éditeur collaboratif, outil avec beaucoup de state local) — dans ce cas, une SPA React avec un backend API sera plus naturelle.
- Ton équipe est principalement JavaScript — le coût d'apprentissage de Blazor n'est pas négligeable.
- Tu as besoin d'un très grand nombre de connexions simultanées avec état interactif — les circuits SignalR ont un coût mémoire par connexion que le SSR statique n'a pas.

**Le cas ekioo.com** est représentatif : contenu statique, bilingue, SEO important, équipe solo C#. Blazor SSR .NET 10 est le fit naturel. La productivité est excellente — j'ai construit le site en deux jours avec Claude, et le modèle de composants Razor est suffisamment expressif pour couvrir tous les besoins.

Ce qui m'a convaincu, ce n'est pas la nouveauté — c'est la cohérence. .NET 10 a rendu le modèle SSR-first prévisible et maintenu. Ce n'est plus un mode expérimental qu'on bricole pour ressembler à Next.js. C'est une opinion architectural claire : rendu statique par défaut, interactivité par exception, performance sans compromis.

C'est un choix que je regrette de ne pas avoir pu faire il y a trois ans.
