.NET 10 refines some of Blazor’s rougher edges while leaving the fundamentals intact.
.NET 10 just landed, and with it come some changes for Blazor. But what’s changed, and how will it affect your projects?
Here’s a quick run down of the new and changed features to keep an eye on if you plan to upgrade.
This is probably the biggest, most impactful change.
PersistentState attribute.You may be using prerendering for your Blazor apps. Prerendering is enabled by default and enables your users to get a snappy, fast-loading first response when they land on your site. (It also helps search engines to index it.)
But, this prerendering creates a tricky problem. If you render a component interactively, with prerendering enabled, your component is initialized and rendered twice.
If you then fetch data in your component, that data will be fetched twice too (once during prerendering, and again when your component renders interactively).
This creates a visible “flash” as the data appears, briefly, then disappears, only to be replaced by the data that was fetched second time around.
Here’s an example of a page that fetches contact details.
We can change this using persistent state.
This is a mechanism whereby you can take that data from the first render, and reuse it for the second render (thereby fixing the flashing state problem, and cutting in half the number of requests to your database).
New in .NET 10 is a handy attribute that can do this for you, for any data you want to persist and re-use this way.
[PersistentState]
public List<Contact>? Contacts { get; set; }
With this, the Contacts data will be fetched once during prerendering, but then persisted by Blazor.
This is actually done by serializing the data and embedding it in the HTML returned to the browser. Then Blazor can pick that state up and use it during interactive rendering.
But PersistentState also has another trick up its sleeve.
Imagine you have some kind of UI state, like the sort options the user has chosen for a grid showing a list of contacts.
You can imagine a scenario where the user selects to sort the contacts by name, in descending order, but then goes off to make a cup of tea. When they come back, their connection to the server has been lost.
Blazor will automatically reconnect but … disaster! They’ve lost their sort order.
This is because that state was initially on one circuit, but by the time they got back from their tea break, Blazor had discarded that circuit and given them a new one. So their contacts flipped back to their original sort order.
PersistentState can help with this problem too.
When you decorate properties with the PersistentState attribute, it now signals to Blazor to take that state and persist it to memory when a circuit is evicted.
[PersistentState]
public ContactSortState? SortState { get; set; }
Then, when the user’s browser reconnects to the server and a new circuit is issued, Blazor can automatically restore that state.
What’s more, if you enable HybridCache for your application, it will be persisted to memory first and then to a second cache (for example, you might want to use a Redis Cache).
The validation system for Blazor has been updated. There are two notable improvements here:
In previous versions of .NET, if you had a form model with a nested property like this:
public class NewContactForm
{
public Contact Contact { get; set; }
public string? Honeypot { get; set; }
}
public class Contact
{
public int Id { get; set; }
[Required]
public string Name { get; set; }
[Required]
public string Email { get; set; }
public string Phone { get; set; }
public string Company { get; set; }
public string Role { get; set; }
}
Then attempted to use it in an EditForm with the DataAnnotations validator, the properties in the Contact object wouldn’t trigger validation.
In .NET 10 you can enable this with a couple of tweaks.
First you’ll need to register the new validation system in Program.cs.
builder.Services.AddValidation();
Then you can decorate your root level type (NewContactForm in this case) with the [ValidatableType] attribute.
Validation attributes enable you to create custom logic for how a field should be validated, but previously they were limited to individual properties.
Now you can use a ValidationAttribute with a class or record.
For example here’s a (contrived!) example of validation logic that attempts to check for “bot-like” behavior in a form.
public class NoBotAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext _)
{
var botWarningSigns = 0;
if (value is not NewContactForm registration)
return new ValidationResult("Invalid registration data.");
// Honeypot field: bots often fill hidden fields
if (!string.IsNullOrEmpty(registration.Honeypot))
{
botWarningSigns += 1;
}
// other checks here...
return botWarningSigns >= 1
? new ValidationResult("Bot-like behavior detected!")
: ValidationResult.Success;
}
}
This checks if the form entry includes a field for a “honeypot” field (a hidden text input that a human wouldn’t typically fill in).
We can then apply this to our NewContactForm class.
using BestPartsDemo.Models;
namespace BestPartsDemo.Components.Pages.Contacts;
[NoBot]
[ValidatableType]
public class NewContactForm
{
public Contact Contact { get; set; }
public string? Honeypot { get; set; }
}
And now we’ll get the validation error if a user fills in that (hidden) field.
There are a few notable improvements to nav and nav links.
Here are some highlights:
NavLink route matchingYou can now configure which component should be displayed in the event of an attempt to navigate to a non-existent page in Blazor, using the new NotFoundPage attribute in your Routes.razor file.
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"/>
...
</Found>
</Router>
Note you may also need to include this line in Program.cs so that this also works in all scenarios (such as static server-side rendering).
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
Previously, when you used the NavLink component, its matching logic would take into account querystring values (and fragments). This effectively meant it broke nav links when you used the querystring.
If you had a NavLink that should be active when someone visited the homepage for your app, but they ended up at your homepage with a querystring value of ?broken=1 (for example), the NavLink would not light up (wouldn’t get its active CSS class).
Now, by default, the querystring will be ignored.
Hand in hand with this, you can implement your own matching logic in a custom NavLink class.
public class ContactsNavLink : NavLink
{
protected override bool ShouldMatch(string uriAbsolute)
{
return uriAbsolute.Contains("/contacts");
}
}
Here, if the URI contains /contacts, the NavLink will be considered active.
The source code for the Blazor Server reconnection modal is now included in new Blazor Web App projects.
If you include a ReconnectModal component and render it in your app, it will be used in preference to the default one.
By default you’ll find this component is rendered in App.razor.
<body>
<Routes/>
<ReconnectModal/>
<script src="@Assets["_framework/blazor.web.js"]"></script>
</body>
There are new JavaScript calls you can use to pause and resume Blazor Server circuits.
For example, you might want to detect when the user navigates away from your site (to another tab) and pause their circuit at that point, then resume it when they come back.
document.addEventListener('visibilitychange', ()=> {
if(document.hidden) {
console.debug("Document hidden - pausing Blazor");
Blazor.pauseCircuit();
} else {
console.debug("Document visible - resuming Blazor");
Blazor.resumeCircuit();
}
});
The blazor scripts are now served as a static web asset with automatic compression and fingerprinting.
This means they will get unique filenames based on their contents (which will ensure users always get the latest version) and the compression takes the blazor.web.js script down by about 76%.
<script src="@Assets["_framework/blazor.web.js"]"></script>
The Blazor script used for Blazor WASM is also fingerprinted and compressed.
For Blazor WASM standalone apps, the same is true, albeit the mechanism is slightly different.
Here you can see the [.{fingerprint}].js placeholder. This will be replaced during build/publish of the app, so long as the OverrideHtmlAssetPlaceholders property is set to trey in the .csproj for the project.
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
+ <OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
</PropertyGroup>
</Project>
There are a slew of new metrics to pour over in .NET 10. A good place to visualize these is using MS Aspire.
Here’s how you can enable them via your ServiceDefaults project for Aspire (metrics and tracing).
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddMeter("Microsoft.AspNetCore.Components")
.AddMeter("Microsoft.AspNetCore.Components.Lifecycle")
.AddMeter("Microsoft.AspNetCore.Components.Server.Circuits");
})
.WithTracing(tracing =>
{
tracing.AddSource(builder.Environment.ApplicationName)
.AddSource("Microsoft.AspNetCore.Components")
.AddSource("Microsoft.AspNetCore.Components.Server.Circuits")
.AddHttpClientInstrumentation();
});
Note the new ones are all the ones that start with Microsoft.AspNetCore.Components.
With that you’ll find these metrics light up in Aspire.
While these were some of the highlights, there are quite a few other changes for Blazor in .NET 10.
Here’s a list of some of the other notable changes for you to explore at your leisure!
NotFound triggered (and programmatically trigger a 404)You can download .NET 10 from Microsoft here.
Using Visual Studio? this release is compatible with Visual Studio 2026, or of course you can use another IDE such as JetBrains Rider.
To learn about Progress Telerik .NET 10 updates, read: Day-Zero Support for .NET 10 Across Progress Telerik.
Jon spends his days building applications using Microsoft technologies (plus, whisper it quietly, a little bit of JavaScript) and his spare time helping developers level up their skills and knowledge via his blog, courses and books. He's especially passionate about enabling developers to build better web applications by mastering the tools available to them. Follow him on Twitter here.