---
title: "Blazor Server in .NET 10: What Actually Changed"
description: "A production account of what Blazor Server in .NET 10 actually means: render pipeline, interactive SSR, streaming, SEO. From running ekioo.com."
date: 2026-05-04T09:00:00+02:00
tags: ["Blazor", ".NET", "SSR", "SEO", "Architecture"]
---

## The Problem With Blazor Articles

Most articles about Blazor .NET 10 look like this: a list of new features copy-pasted from the release notes, a few code snippets with no context, and an enthusiastic conclusion along the lines of "Blazor has never been better!"

That's not what you'll find here.

Ekioo.com runs on **Blazor Server + .NET 10 in production**. Not a demo project, not a tutorial: a real site with bilingual content, a blog, a sitemap, project pages, and SEO requirements. This article is a field report on what I've actually observed — what changed, what got better, and what still costs you something.

---

## The .NET 10 Render Pipeline: What Actually Moved

### Before: Three Modes, Three Models

Up through .NET 8, Blazor had three distinct and mutually exclusive render modes:

- **Blazor Server**: the DOM is managed server-side, interactions travel over SignalR.
- **Blazor WebAssembly**: the entire runtime is downloaded to the browser, no server at runtime.
- **Blazor SSR (static)**: server-side HTML on first load, no interactivity afterwards.

.NET 8 introduced **render modes** — the idea that a single project can mix these modes at component granularity. One component can be `@rendermode InteractiveServer` (SignalR), another `@rendermode InteractiveWebAssembly`, another purely static. Elegant on paper. Treacherous in practice.

### .NET 9: Stabilization, Not Revolution

.NET 9 mostly fixed the friction introduced by .NET 8. The render mode system became more predictable, hydration errors less frequent, streaming SSR behavior more stable. It's the version where Blazor SSR went from "promising but fragile" to "seriously usable."

### .NET 10: Static Rendering Becomes First Class

What changes in .NET 10 is that **static rendering (Static SSR) becomes the reference mode**. The default templates generate SSR-first applications. Interactivity is opt-in, localized, component by component.

Concretely:

- Pages are rendered as static SSR by default — complete HTML sent to the client, no JavaScript required.
- Components that need interactivity explicitly declare their render mode.
- The SignalR circuit is opened only where necessary, not globally for the whole application.

For a site like ekioo.com — mostly static content, a handful of isolated interactions — this is the model that makes sense. And it's the model that .NET 10 stops treating as a special case.

<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="bdn10en-dots" width="20" height="20" patternUnits="userSpaceOnUse">
      <circle cx="10" cy="10" r="0.8" fill="#1a2535"/>
    </pattern>
    <filter id="bdn10en-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="bdn10en-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(#bdn10en-dots)"/>
  <text x="380" y="36" font-family="'Courier New',Courier,monospace" font-size="11" fill="#2d4060" text-anchor="middle" letter-spacing="4">RENDER PIPELINE — 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">component matched</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">declared?</text>
  <text x="537" y="74" font-family="'Courier New',Courier,monospace" font-size="10" fill="#3b82f6" text-anchor="middle">yes</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">INTERACTIVE (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">SignalR circuit / component</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">no</text>
  <text x="462" y="164" font-family="'Courier New',Courier,monospace" font-size="9" fill="#374151">(default)</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">progressive HTML flush</text>
  <text x="116" y="272" font-family="'Courier New',Courier,monospace" font-size="8.5" fill="#2d3748" text-anchor="middle">placeholders → content</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(#bdn10en-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">DEFAULT MODE</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">Full HTML · 0 JS · 0 SignalR circuit</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(#bdn10en-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">HTTP RESPONSE</text>
  <text x="380" y="374" font-family="'Courier New',Courier,monospace" font-size="13" fill="#c4b5fd" text-anchor="middle">HTML → Browser · 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="82" height="26" rx="13" fill="#0d1a10"/>
  <text x="211" y="401" font-family="'Courier New',Courier,monospace" font-size="8.5" fill="#4ade80" text-anchor="middle">LCP = content</text>
  <rect x="260" y="384" width="62" height="26" rx="13" fill="#0d1a10"/>
  <text x="291" y="401" font-family="'Courier New',Courier,monospace" font-size="8.5" fill="#4ade80" text-anchor="middle">CLS = 0</text>
  <rect x="330" y="384" width="80" height="26" rx="13" fill="#0d1420"/>
  <text x="370" y="401" font-family="'Courier New',Courier,monospace" font-size="8.5" fill="#60a5fa" text-anchor="middle">0 JS needed</text>
  <rect x="418" y="384" width="82" height="26" rx="13" fill="#0d1420"/>
  <text x="459" y="401" font-family="'Courier New',Courier,monospace" font-size="8.5" fill="#60a5fa" text-anchor="middle">Googlebot ✓</text>
  <rect x="508" y="384" width="184" height="26" rx="13" fill="#130d28"/>
  <text x="600" y="401" font-family="'Courier New',Courier,monospace" font-size="8.5" fill="#a78bfa" text-anchor="middle">native SEO · 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>

---

## Interactive Blazor SSR: The Component Model That Changed

### What "Interactive" Means Now

Before .NET 8, "Blazor Server" meant that the entire component lifecycle went through SignalR. `OnInitializedAsync`, `OnParametersSetAsync`, event handlers — everything traveled over the wire. That came at a cost: latency, server resources, network fragility.

In .NET 10, the lifecycle for static components is much simpler: they live for the duration of a render, on the server, then disappear. No server-side state, no open circuit. The page is rendered, the HTML is sent, done.

When you add `@rendermode InteractiveServer` to a component, you're explicitly opting into the SignalR model for that component only. The rest of the page stays static.

### The Pitfalls of Mixing Static and Interactive

This hybrid model is powerful but requires discipline.

**Pitfall 1: complex parameters don't cross the boundary**

A static component cannot pass a complex C# object to an interactive component. The static/interactive boundary is serialized — you can only pass primitive types or JSON-serializable types. If you try to pass an `IEnumerable<MyComplexType>` from a static parent to an interactive child, it won't compile or will fail at runtime.

The solution: pass IDs or keys, and let the interactive component fetch the data itself.

**Pitfall 2: services aren't shared the same way**

In interactive mode, `Scoped` services are tied to the SignalR circuit — they survive intra-SPA navigation. In static mode, they're tied to the HTTP request — recreated on every navigation. If you have a service maintaining user state in memory, its behavior changes drastically depending on the render mode.

For ekioo.com, I simplified: no in-memory state, everything recalculated from markdown files on each request. A deliberate choice to avoid this class of bugs.

**Pitfall 3: prerendering by default**

In `InteractiveServer` mode, Blazor performs a static prerender before establishing the SignalR circuit. That means `OnInitializedAsync` runs twice: once server-side (static render), once when the circuit opens (interactive render). If you're making HTTP calls in `OnInitializedAsync`, they double.

The fix: check `!FirstRender` or use `OnAfterRenderAsync(bool firstRender)` for operations that should only run once.

---

## Streaming Rendering: Real Behavior and Use Cases

### What Streaming SSR Actually Does

Streaming rendering (enabled with `@attribute [StreamRendering]`) lets Blazor send HTML progressively as components render, without waiting for the whole page to be ready.

Concretely: if a component is loading slow data (a database query, an API call), Blazor can send the rest of the page immediately and "stream" that component's content when its data becomes available.

From the browser's perspective: the page starts displaying very quickly, with placeholders for pending parts, then the final content replaces the placeholders with no visible reload.

### Use Cases on ekioo.com

On ekioo.com, the blog lists articles from markdown files on disk. That's fast — no need for streaming. But if I had a "recent posts" section with data from a third-party API (variable latency), streaming would be relevant: I could display the rest of the page instantly and let that section load at its own pace.

### Pitfalls to Know

Streaming SSR changes how CSS selectors and JavaScript initialized on load interact with the DOM. If you have scripts that run on `DOMContentLoaded` and look for DOM elements, they may run before streamed elements are available.

On ekioo.com, the particle animation is initialized via a classic script — I had to make sure it attaches to the page's canvas and not to elements that might arrive via streaming later.

Another point: SEO tools and crawlers increasingly understand streaming (Googlebot has supported JavaScript rendering for a long time), but classic audit tools may see incomplete content if they don't handle chunked responses.

---

## Static SSR and SEO: What It Really Changes

### The Core Problem: Blazor SPA and Googlebot

Classic Blazor WebAssembly was a serious SEO problem. Googlebot receives an empty HTML shell, waits for WebAssembly to load (variable timeout), and indexes potentially incomplete or delayed content. Not ideal.

Blazor Server partially avoided this — prerender sent complete HTML on the first load. But the server-side render time and establishing the SignalR circuit added latency, and Core Web Vitals metrics suffered.

### Static SSR: Complete HTML, Zero JavaScript Required

With pure Static SSR, the server generates complete HTML and sends it directly. No JavaScript needed to see the content. Googlebot receives exactly what the user sees. It's the behavior of a PHP site or a static site generator — but with the productivity of C# and the Razor component model.

For ekioo.com, here's what that looks like in practice:

**Time to First Byte (TTFB)**: on a modest VPS (2 vCPU, 4 GB RAM), static pages serve in **< 50 ms** after warm-up. The cold start (dotnet process startup) is the only notable latency, but it's a one-shot.

**Largest Contentful Paint (LCP)**: without JavaScript blocking the render, the LCP is the main content — not a spinner or an empty skeleton. On ekioo.com's blog pages, the LCP is the article title, visible as soon as the HTML arrives.

**Cumulative Layout Shift (CLS)**: streaming SSR can introduce CLS if placeholders have different dimensions than the final content. With pure Static SSR (no streaming), this risk doesn't exist: the layout is fixed from the first byte.

### The Sitemap as an Authority Signal

Ekioo.com exposes two sitemaps: `/sitemap.xml` (FR + EN) and `/sitemap-content.xml` (raw markdown). The `lastmod` dates are pulled directly from the YAML frontmatter of the markdown files.

This isn't decoration: Googlebot uses `lastmod` to prioritize recrawling. An incorrect date (too old or too recent) blurs that signal. With Blazor SSR, I generate these dates dynamically from files on disk — no possible mismatch between the actual content and what the sitemap declares.

---

## A Concrete Example: Bilingual Navigation on ekioo.com

A case that illustrates the SSR model's constraints well:

Ekioo.com is bilingual (FR/EN). Language detection happens only from the URL — no cookie, no `Accept-Language` header, no automatic redirect. A deliberate SEO decision: Google recommends distinct URLs per language, not redirects based on geolocation.

Each Razor page declares two routes:

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

And a cascading parameter passes the language to all child components:

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

This pattern works perfectly with Static SSR. There's no navigation state to maintain between pages — each URL is an independent request, served by the right component with the right language.

With classic Blazor Server (global interactive mode), this type of navigation could create inconsistencies: the language could "stick" from the previous page if the SignalR circuit remained open and the state wasn't properly reset. With pure SSR, this problem doesn't exist structurally.

---

## Verdict: Who Should Use .NET 10 + Blazor SSR in 2026

**It's the right choice if:**

- You're building a mostly static site (blog, portfolio, documentation, corporate site) with localized interactivity (contact form, search component, shopping cart).
- You work in C# and want to avoid context-switching between a .NET backend and a React/Vue/Angular frontend.
- SEO is critical — you want crawlers to see exactly what users see, without depending on JavaScript rendering.
- You're targeting solid Core Web Vitals performance without a CDN and without complex infrastructure.

**It's not the right choice if:**

- You're building a highly interactive application (real-time dashboard, collaborative editor, tool with lots of local state) — in that case, a React SPA with an API backend will be more natural.
- Your team is primarily JavaScript — the learning cost of Blazor is non-trivial.
- You need a very large number of simultaneous stateful interactive connections — SignalR circuits have a memory cost per connection that static SSR doesn't.

**The ekioo.com case** is representative: static content, bilingual, SEO-critical, solo C# team. Blazor SSR .NET 10 is the natural fit. Productivity is excellent — I built the site in two days with Claude, and the Razor component model is expressive enough to cover all the requirements.

What convinced me wasn't the novelty — it's the coherence. .NET 10 has made the SSR-first model predictable and opinionated. It's no longer an experimental mode you hack together to look like Next.js. It's a clear architectural stance: static rendering by default, interactivity by exception, performance without compromise.

It's a choice I wish I could have made three years ago.
