Start Debugging

Migrate a Blazor Server app to Blazor United (Blazor Web App) in .NET 11

A step-by-step checklist to move a standalone Blazor Server app to the unified Blazor Web App template on .NET 11, keeping every page on InteractiveServer with zero behaviour change.

If you have a standalone Blazor Server app (dotnet new blazorserver) and you want to move it onto the unified template that was nicknamed “Blazor United” during the .NET 8 preview cycle and shipped as Blazor Web App, the migration is mostly mechanical and usually takes half a day for a small app, one to three days for a large one. Nothing about your component code has to change. What changes is the host: _Host.cshtml disappears, routing moves into a Routes component, Program.cs swaps MapBlazorHub for MapRazorComponents, and you declare a render mode explicitly. Keep every page on @rendermode InteractiveServer and behaviour stays identical to .NET 7-era Blazor Server. The single thing that bites is prerendering double-execution. This guide targets .NET 11 (preview at time of writing, GA scheduled November 2026) and the Microsoft.AspNetCore.Components 11.0.x package set.

Why move off the standalone Server template

The standalone blazorserver template still works in .NET 11, so this is not a forced migration. Move when one of these is a real outcome you want, not before:

If none of those apply, a <TargetFramework>net11.0</TargetFramework> bump on your existing standalone Server app is a valid alternative and is not this migration.

What breaks

AreaChangeSeverity
Host pagePages/_Host.cshtml and _Layout.cshtml are replaced by a root App.razor host componenthigh
Routing<Router> moves out of App.razor into a new Routes.razorhigh
Program.cs startupAddServerSideBlazor() + MapBlazorHub() + MapFallbackToPage("/_Host") replaced by AddRazorComponents().AddInteractiveServerComponents() + MapRazorComponents<App>().AddInteractiveServerRenderMode()high
Render modeInteractivity is opt-in per component or global; no implicit “whole app is interactive”high
PrerenderingOnInitialized/OnInitializedAsync run twice (prerender pass + interactive pass) by defaultmedium
Client script_framework/blazor.server.js becomes _framework/blazor.web.jsmedium
Auth wiringCascadingAuthenticationState component is replaced by AddCascadingAuthenticationState() DImedium
HttpContext accessHttpContext is only available during static SSR, not inside an interactive componentmedium
App.razor semanticsApp.razor is no longer the router; it is the HTML document shelllow

Pre-flight checklist

Migration steps

1. Bump the target framework and packages

Edit the .csproj. The SDK stays Microsoft.NET.Sdk.Web.

<!-- .NET 11 -->
<PropertyGroup>
  <TargetFramework>net11.0</TargetFramework>
  <Nullable>enable</Nullable>
  <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

Bump any Microsoft.AspNetCore.Components.* package references to 11.0.x. Remove Microsoft.AspNetCore.Components.Web if it is referenced explicitly; it is part of the framework reference for web SDK projects.

Verify: dotnet restore succeeds and dotnet build fails only with errors about the host page and Program.cs (expected at this point), not package resolution errors.

2. Create the App.razor host component

In the standalone Server template, the HTML document lives in Pages/_Host.cshtml and Pages/_Layout.cshtml. Move that markup into a new root App.razor (delete the old App.razor router first, or rename it). The <component> tag helper that bootstrapped the root becomes <Routes />.

@* .NET 11 - App.razor is now the HTML document shell, not the router *@
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <base href="/" />
    <link rel="stylesheet" href="app.css" />
    <link rel="stylesheet" href="YourApp.styles.css" />
    <HeadOutlet />
</head>
<body>
    <Routes />
    <script src="_framework/blazor.web.js"></script>
</body>
</html>

Two changes that are easy to miss: the script is blazor.web.js (not blazor.server.js), and <HeadOutlet /> plus <Routes /> are components, so they pick up whatever render mode you assign in step 6.

Verify: the file compiles as a Razor component (no @page directive, no @model).

3. Move routing into Routes.razor

Create Routes.razor in the project root and paste the <Router> block that used to live in the old App.razor.

@* .NET 11 - Routes.razor holds the router that used to be in App.razor *@
<Router AppAssembly="@typeof(Program).Assembly">
    <Found Context="routeData">
        <RouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)" />
        <FocusOnNavigate RouteData="@routeData" Selector="h1" />
    </Found>
    <NotFound>
        <PageTitle>Not found</PageTitle>
        <LayoutView Layout="@typeof(Layout.MainLayout)">
            <p role="alert">Sorry, there's nothing at this address.</p>
        </LayoutView>
    </NotFound>
</Router>

Verify: a dotnet build no longer complains about a missing Routes type.

4. Enable render-mode shorthands in _Imports.razor

Add this line to _Imports.razor so you can write InteractiveServer instead of fully qualifying it:

@* .NET 11 *@
@using static Microsoft.AspNetCore.Components.Web.RenderMode

Verify: @rendermode InteractiveServer in a component resolves without a using error.

5. Rewrite Program.cs

Swap the Blazor Server registration and endpoints for the Razor Components equivalents.

// .NET 11, C# 14 - Blazor Web App startup
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents();

// keep your existing app services here (DbContext, HttpClient, etc.)

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error", createScopeForErrors: true);
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAntiforgery();

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();

app.Run();

Delete AddServerSideBlazor(), app.MapBlazorHub(), and app.MapFallbackToPage("/_Host"). The app.UseAntiforgery() call is new and required; the Web App template enables antiforgery middleware by default and form posts fail without it.

Verify: dotnet build succeeds with zero errors.

6. Turn the app interactive (global render mode)

The lowest-risk migration makes the whole app InteractiveServer, reproducing the old behaviour exactly. Set the render mode on <Routes /> and <HeadOutlet /> in App.razor:

@* .NET 11 - global InteractiveServer, matches standalone Blazor Server behaviour *@
<HeadOutlet @rendermode="InteractiveServer" />
...
<Routes @rendermode="InteractiveServer" />

Verify: run dotnet run, open the app, and confirm interactive components (buttons, @onclick, EditForm submits) work and the browser holds an open WebSocket to /_blazor. Once this works, you can later drop individual pages to @rendermode StaticServer or escalate islands to WebAssembly, but that is post-migration work.

7. Handle prerendering double-execution

This is the one behavioural change that surprises people. With an interactive render mode, Blazor prerenders static HTML first, then renders again over the live circuit, so OnInitialized and OnInitializedAsync run twice. The old standalone Server default (render-mode="ServerPrerendered") had the same property, but many apps used render-mode="Server" and never saw it.

You have three options. The cleanest in .NET 11 is the declarative [PersistentState] attribute (added in .NET 10): fetch once during prerender, serialize into the HTML, restore on the interactive pass.

// .NET 11 - fetch once, survive the prerender-to-interactive handoff
public partial class Dashboard : ComponentBase
{
    [PersistentState]
    public List<Order>? Orders { get; set; }

    [Inject] public required IOrderService OrderService { get; init; }

    protected override async Task OnInitializedAsync()
    {
        // Orders is non-null on the interactive pass: state was restored,
        // so the service is not hit a second time.
        Orders ??= await OrderService.GetRecentAsync();
    }
}

If you do not want to fetch during prerender at all, disable prerendering for that boundary:

@* .NET 11 - skip the prerender pass entirely for this component tree *@
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />

Verify: put a breakpoint or log line in each side-effecting OnInitializedAsync and confirm it runs once per real navigation, not twice.

8. Re-wire authentication and authorization

If your app used <CascadingAuthenticationState> wrapping the router, remove that component and register it in DI instead, then switch RouteView to AuthorizeRouteView in Routes.razor.

// .NET 11 - Program.cs
builder.Services.AddCascadingAuthenticationState();
@* .NET 11 - Routes.razor, authorized routing *@
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)" />

Verify: hit a [Authorize] page while signed out and confirm you are redirected, then signed in and confirm access.

9. Delete the dead host files

Remove Pages/_Host.cshtml, Pages/_Layout.cshtml, and any app.MapRazorPages() call that existed only to serve _Host. Drop AddRazorPages() from Program.cs if nothing else uses Razor Pages.

Verify: dotnet build is clean and the app still serves every route.

Verification: the post-migration smoke test

Run all of these before you merge:

Rollback plan

This migration is reversible only if you kept the branch from the pre-flight step. Once you delete _Host.cshtml and rewrite Program.cs, there is no in-place toggle back to the standalone Server model. Roll back by checking out the pre-migration commit, not by editing forward. Because the change is structural and not a data migration, there is nothing to undo in your database or storage. Branch, do the work, verify against the smoke test, and merge only when green.

Gotchas we hit

The destination here is almost always “every page on InteractiveServer, prerendering handled”. That is the migration that changes nothing the user can see. Adding Static Server pages, WebAssembly islands, and Auto components is the payoff you collect afterwards, one component at a time, with no further structural churn.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back