name: webforms-migration description: "Migrate ASP.NET Web Forms applications (.aspx/.ascx/.master) to Blazor Server using BlazorWebFormsComponents (BWFC). Use this skill when converting Web Forms markup, code-behind, Master Pages, User Controls, or data-binding patterns to Blazor equivalents."
Web Forms → Blazor Migration Skill
This skill provides complete transformation rules for migrating ASP.NET Web Forms applications to Blazor Server using the BlazorWebFormsComponents (BWFC) NuGet package. The BWFC library provides Blazor components with identical names, attributes, and HTML output to ASP.NET Web Forms controls — enabling migration with minimal markup changes.
Core Principle
Strip
asp:andrunat="server", keep everything else, and it just works.
BWFC components match Web Forms control names, property names, and rendered HTML. A well-structured Web Forms page can often be migrated by removing the asp: prefix, removing runat="server", and making a small set of structural adjustments.
Migration Recipe (Step-by-Step)
Step 1: Create Blazor Server Project
dotnet new blazor -n MyBlazorApp --interactivity Server
cd MyBlazorApp
dotnet add package Fritz.BlazorWebFormsComponents
Step 2: Configure _Imports.razor
Add these to the project-level _Imports.razor:
@using BlazorWebFormsComponents
@using BlazorWebFormsComponents.Enums
Step 3: Register BWFC Services
In Program.cs:
builder.Services.AddBlazorWebFormsComponents();
Step 3b: Configure Static Asset Middleware
In Program.cs, use MapStaticAssets() (not UseStaticFiles()) to serve both wwwroot/ content AND _framework/blazor.web.js:
app.MapStaticAssets(); // Required for _framework/blazor.web.js in .NET 9+
app.UseAntiforgery();
⚠️ CRITICAL: Using
app.UseStaticFiles()alone will NOT serve_framework/blazor.web.jsin .NET 9+, which means the Blazor runtime never loads and NO interactivity works on any page.
Step 3c: Add launchSettings.json
Create Properties/launchSettings.json so dotnet run uses Development mode (required for MapStaticAssets() to resolve framework files during development):
{
"profiles": {
"MyBlazorApp": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:5001;http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
Without this file,
dotnet rundefaults to Production mode, andMapStaticAssets()expects a published manifest that doesn't exist during development — resulting inblazor.web.js404.
Step 4: Add BWFC JavaScript
In App.razor or the host page <head>:
<script src="_content/Fritz.BlazorWebFormsComponents/js/Basepage.js"></script>
Step 5: Migrate Master Page → Blazor Layout
See Master Page Migration Rules.
Step 6: Migrate Pages
For each .aspx page, apply the Page Migration Rules and Control Translation Table.
Step 7: Migrate Code-Behind
See Code-Behind Migration Rules.
Step 8: Wire Data Access
Replace DataSource controls and DataBind() calls with service injection. See Data Binding Migration.
Page Migration Rules
File Conversion
| Web Forms | Blazor |
|---|---|
MyPage.aspx |
MyPage.razor |
MyPage.aspx.cs |
MyPage.razor.cs (partial class) or @code { } block |
MyControl.ascx |
MyControl.razor |
MyControl.ascx.cs |
MyControl.razor.cs |
Site.Master |
MainLayout.razor |
Site.Master.cs |
MainLayout.razor.cs |
Directive Conversion
| Web Forms Directive | Blazor Equivalent |
|---|---|
<%@ Page Title="X" Language="C#" MasterPageFile="~/Site.Master" AutoEventWireup="true" CodeBehind="Y.aspx.cs" Inherits="NS.Y" %> |
@page "/route" |
<%@ Master Language="C#" ... %> |
(remove — layouts don't need directives) |
<%@ Control Language="C#" ... %> |
(remove — components don't need directives) |
<%@ Register TagPrefix="uc" TagName="X" Src="~/Controls/X.ascx" %> |
@using MyApp.Components (if needed) |
<%@ Import Namespace="X" %> |
@using X |
Drop these attributes entirely (no Blazor equivalent):
AutoEventWireupCodeBehind/CodeFileInherits(unless using@inheritsfor a base class)EnableViewState/ViewStateModeMasterPageFile(layouts are set differently — see Layout section)ValidateRequestMaintainScrollPositionOnPostBack
Expression Conversion
| Web Forms Expression | Blazor Equivalent | Notes |
|---|---|---|
<%: expression %> |
@(expression) |
HTML-encoded output |
<%= expression %> |
@(expression) |
Blazor always HTML-encodes |
<%# Item.Property %> |
@context.Property |
Inside data-bound templates |
<%#: Item.Property %> |
@context.Property |
Same — Blazor always encodes |
<%# Eval("Property") %> |
@context.Property |
Direct property access |
<%# Bind("Property") %> |
@bind-Value="context.Property" |
Two-way binding |
<%# string.Format("{0:C}", Item.Price) %> |
@context.Price.ToString("C") |
Format in code |
<%$ RouteValue:id %> |
@Id (with [Parameter]) |
Route parameters |
<%-- comment --%> |
@* comment *@ |
Razor comments |
<% if (condition) { %> |
@if (condition) { |
Control flow |
<% foreach (var x in items) { %> |
@foreach (var x in items) { |
Loops |
Route URL Conversion
| Web Forms | Blazor |
|---|---|
href="~/Products" |
href="/Products" |
NavigateUrl="~/Products/<%: Item.ID %>" |
NavigateUrl="@($"/Products/{context.ID}")" |
<%: GetRouteUrl("ProductRoute", new { id = Item.ID }) %> |
@($"/Products/{context.ID}") |
Response.Redirect("~/Products") |
NavigationManager.NavigateTo("/Products") |
Response.RedirectToRoute("ProductRoute", new { id }) |
NavigationManager.NavigateTo($"/Products/{id}") |
Content/Layout Conversion
| Web Forms | Blazor |
|---|---|
<asp:Content ContentPlaceHolderID="MainContent" runat="server"> |
(remove — page body IS the content) |
</asp:Content> |
(remove) |
<asp:Content ContentPlaceHolderID="HeadContent" runat="server"> |
<HeadContent> ... </HeadContent> |
<asp:ContentPlaceHolder ID="MainContent" runat="server" /> |
@Body (in layout) |
Form Wrapper
Web Forms wraps everything in <form runat="server">. In Blazor:
- Remove the
<form runat="server">wrapper entirely - For forms that need validation, wrap with
<EditForm Model="@model">instead - Individual BWFC validation controls work inside
<EditForm>the same way
Control Translation Table
Simple Controls (Trivial Migration)
These controls require only removing asp: and runat="server":
| Web Forms | BWFC | Changes |
|---|---|---|
<asp:Label ID="x" runat="server" Text="Hello" CssClass="title" /> |
<Label @ref="x" Text="Hello" CssClass="title" /> |
Remove asp:, runat; ID → @ref (if referenced) |
<asp:Literal ID="x" runat="server" Text="Hello" /> |
<Literal Text="Hello" /> |
Remove asp:, runat |
<asp:HyperLink NavigateUrl="~/About" Text="About" runat="server" /> |
<HyperLink NavigateUrl="/About" Text="About" /> |
Remove asp:, runat; ~/ → / |
<asp:Image ImageUrl="~/images/logo.png" runat="server" /> |
<Image ImageUrl="/images/logo.png" /> |
Remove asp:, runat; ~/ → / |
<asp:Panel CssClass="container" runat="server"> |
<Panel CssClass="container"> |
Remove asp:, runat |
<asp:PlaceHolder runat="server"> |
<PlaceHolder> |
Remove asp:, runat |
<asp:HiddenField Value="x" runat="server" /> |
<HiddenField Value="x" /> |
Remove asp:, runat |
Form Controls (Easy Migration)
| Web Forms | BWFC | Notes |
|---|---|---|
<asp:TextBox ID="Name" runat="server" /> |
<TextBox @bind-Text="model.Name" /> |
Add @bind-Text for data binding |
<asp:TextBox TextMode="Password" runat="server" /> |
<TextBox TextMode="Password" @bind-Text="model.Password" /> |
TextMode preserved |
<asp:TextBox TextMode="MultiLine" Rows="5" runat="server" /> |
<TextBox TextMode="Multiline" Rows="5" @bind-Text="model.Notes" /> |
Note: Multiline not MultiLine |
<asp:DropDownList ID="Category" runat="server" /> |
<DropDownList @bind-SelectedValue="model.Category" Items="categories" /> |
Bind items + selected value |
<asp:CheckBox ID="Active" runat="server" Checked="true" /> |
<CheckBox @bind-Checked="model.Active" /> |
@bind-Checked |
<asp:RadioButton GroupName="G" runat="server" /> |
<RadioButton GroupName="G" /> |
Same attributes |
<asp:FileUpload ID="Upload" runat="server" /> |
<FileUpload /> |
Uses InputFile internally |
<asp:Button Text="Submit" OnClick="Submit_Click" runat="server" /> |
<Button Text="Submit" OnClick="Submit_Click" /> |
OnClick is now EventCallback |
<asp:LinkButton Text="Edit" CommandName="Edit" runat="server" /> |
<LinkButton Text="Edit" CommandName="Edit" /> |
Same attributes |
<asp:ImageButton ImageUrl="~/btn.png" OnClick="Btn_Click" runat="server" /> |
<ImageButton ImageUrl="/btn.png" OnClick="Btn_Click" /> |
~/ → / |
Validation Controls (Easy Migration)
Validation controls are nearly 1:1 — same names, same attributes:
| Web Forms | BWFC | Notes |
|---|---|---|
<asp:RequiredFieldValidator ControlToValidate="Name" ErrorMessage="Required" runat="server" /> |
<RequiredFieldValidator ControlToValidate="Name" ErrorMessage="Required" /> |
Remove asp:, runat |
<asp:CompareValidator ControlToCompare="Password" ControlToValidate="Confirm" runat="server" /> |
<CompareValidator ControlToCompare="Password" ControlToValidate="Confirm" /> |
Same |
<asp:RangeValidator MinimumValue="1" MaximumValue="100" Type="Integer" runat="server" /> |
<RangeValidator MinimumValue="1" MaximumValue="100" Type="Integer" /> |
Same |
<asp:RegularExpressionValidator ValidationExpression="\d+" runat="server" /> |
<RegularExpressionValidator ValidationExpression="\d+" /> |
Same |
<asp:CustomValidator OnServerValidate="Validate" runat="server" /> |
<CustomValidator OnServerValidate="Validate" /> |
Same |
<asp:ValidationSummary DisplayMode="BulletList" runat="server" /> |
<ValidationSummary DisplayMode="BulletList" /> |
Same |
<asp:ModelErrorMessage ModelStateKey="key" runat="server" /> |
<ModelErrorMessage ModelStateKey="key" /> |
Same |
Data Controls (Medium Migration)
Data controls require additional changes for data binding:
GridView
<!-- Web Forms -->
<asp:GridView ID="ProductGrid" runat="server"
ItemType="WingtipToys.Models.Product"
SelectMethod="GetProducts"
AutoGenerateColumns="false"
AllowPaging="true" PageSize="10">
<Columns>
<asp:BoundField DataField="Name" HeaderText="Product" />
<asp:TemplateField HeaderText="Price">
<ItemTemplate><%#: Item.UnitPrice.ToString("C") %></ItemTemplate>
</asp:TemplateField>
</Columns>
</asp:GridView>
<!-- Blazor with BWFC -->
<GridView Items="products" TItem="Product"
AutoGenerateColumns="false"
AllowPaging="true" PageSize="10">
<Columns>
<BoundField DataField="Name" HeaderText="Product" />
<TemplateField HeaderText="Price">
<ItemTemplate Context="Item">@Item.UnitPrice.ToString("C")</ItemTemplate>
</TemplateField>
</Columns>
</GridView>
Key changes:
ItemType→TItem(generic type parameter)SelectMethod="GetProducts"→Items="products"(bind to a property loaded inOnInitializedAsync)<%#: Item.X %>→@Item.Xinside templates- Add
Context="Item"to<ItemTemplate>for naming the loop variable
ListView
<!-- Web Forms -->
<asp:ListView ID="ProductList" runat="server"
ItemType="WingtipToys.Models.Product"
SelectMethod="GetProducts">
<ItemTemplate>
<div class="product">
<h3><%#: Item.ProductName %></h3>
<asp:Image ImageUrl="<%#: Item.ImagePath %>" runat="server" />
<p><%#: Item.UnitPrice.ToString("C") %></p>
</div>
</ItemTemplate>
</asp:ListView>
<!-- Blazor with BWFC -->
<ListView Items="products" TItem="Product">
<ItemTemplate Context="Item">
<div class="product">
<h3>@Item.ProductName</h3>
<Image ImageUrl="@Item.ImagePath" />
<p>@Item.UnitPrice.ToString("C")</p>
</div>
</ItemTemplate>
</ListView>
FormView
<!-- Web Forms -->
<asp:FormView ID="ProductDetail" runat="server"
ItemType="WingtipToys.Models.Product"
SelectMethod="GetProduct"
RenderOuterTable="false">
<ItemTemplate>
<h2><%#: Item.ProductName %></h2>
<p><%#: Item.Description %></p>
<p>Price: <%#: Item.UnitPrice.ToString("C") %></p>
</ItemTemplate>
</asp:FormView>
<!-- Blazor with BWFC -->
<FormView DataItem="product" TItem="Product" RenderOuterTable="false">
<ItemTemplate Context="Item">
<h2>@Item.ProductName</h2>
<p>@Item.Description</p>
<p>Price: @Item.UnitPrice.ToString("C")</p>
</ItemTemplate>
</FormView>
Key changes:
SelectMethod→DataItem(single object, loaded inOnInitializedAsync)Itemsfor collection-bound controls,DataItemfor single-record controls
Repeater
<!-- Blazor with BWFC -->
<Repeater Items="items" TItem="MyItem">
<ItemTemplate Context="Item">
<div>@Item.Name — @Item.Value</div>
</ItemTemplate>
<SeparatorTemplate><hr /></SeparatorTemplate>
</Repeater>
Navigation Controls
| Web Forms | BWFC | Notes |
|---|---|---|
<asp:Menu> |
<Menu> |
MenuItem structure preserved |
<asp:SiteMapPath> |
<SiteMapPath> |
Provide SiteMapNode data |
<asp:ScriptManager runat="server" /> |
<ScriptManager /> |
Renders nothing — correct for Blazor |
Login Controls
| Web Forms | BWFC | Notes |
|---|---|---|
<asp:Login> |
<Login> |
Wire auth provider via service |
<asp:LoginView> |
<LoginView> |
Uses AuthenticationState |
<asp:LoginStatus> |
<LoginStatus> |
Uses AuthenticationState |
<asp:LoginName> |
<LoginName> |
Uses AuthenticationState |
Code-Behind Migration
Lifecycle Methods
| Web Forms | Blazor | Notes |
|---|---|---|
Page_Load(object sender, EventArgs e) |
protected override async Task OnInitializedAsync() |
First load |
Page_PreRender(...) |
protected override async Task OnParametersSetAsync() |
Before each render |
Page_Init(...) |
protected override void OnInitialized() |
Sync initialization |
IsPostBack check |
(not needed) | Blazor doesn't have postback |
Pattern:
// Web Forms
protected void Page_Load(object sender, EventArgs e)
{
if (!IsPostBack)
{
products = GetProducts();
GridView1.DataBind();
}
}
// Blazor
protected override async Task OnInitializedAsync()
{
products = await ProductService.GetProductsAsync();
}
Event Handlers
// Web Forms
protected void SubmitBtn_Click(object sender, EventArgs e)
{
// handle click
Response.Redirect("~/Confirmation");
}
// Blazor
private void SubmitBtn_Click()
{
// handle click
NavigationManager.NavigateTo("/Confirmation");
}
Navigation
| Web Forms | Blazor |
|---|---|
Response.Redirect("~/path") |
NavigationManager.NavigateTo("/path") |
Response.RedirectToRoute(...) |
NavigationManager.NavigateTo($"/path/{param}") |
Server.Transfer("~/page.aspx") |
NavigationManager.NavigateTo("/page") |
Session State
| Web Forms | Blazor |
|---|---|
Session["key"] = value; |
Inject a scoped service; use ProtectedSessionStorage |
Application["key"] |
Use a singleton service |
Cache["key"] |
Use IMemoryCache or IDistributedCache |
ViewState["key"] |
Use component fields (state is per-component) |
Query String / Route Parameters
// Web Forms (Model Binding)
public IQueryable<Product> GetProducts([QueryString] int? categoryId) { ... }
// Blazor
[SupplyParameterFromQuery] public int? CategoryId { get; set; }
protected override async Task OnInitializedAsync()
{
products = await ProductService.GetProductsAsync(CategoryId);
}
// Web Forms (RouteData)
public void GetProduct([RouteData] int productId) { ... }
// Blazor
@page "/Products/{ProductId:int}"
[Parameter] public int ProductId { get; set; }
Data Access
// Web Forms (SelectMethod pattern)
public IQueryable<Product> GetProducts()
{
var db = new ProductContext();
return db.Products;
}
// Blazor (Service injection)
@inject IProductService ProductService
private List<Product> products = new();
protected override async Task OnInitializedAsync()
{
products = await ProductService.GetProductsAsync();
}
Key change: Replace inline DbContext usage with injected services. Register in Program.cs:
builder.Services.AddDbContext<ProductContext>();
builder.Services.AddScoped<IProductService, ProductService>();
Master Page Migration
Web Forms Master Page Structure
<%@ Master Language="C#" CodeBehind="Site.master.cs" Inherits="MyApp.SiteMaster" %>
<!DOCTYPE html>
<html>
<head runat="server">
<title><%: Page.Title %></title>
<asp:ContentPlaceHolder ID="HeadContent" runat="server" />
</head>
<body>
<form runat="server">
<asp:ScriptManager runat="server" />
<header>
<nav>
<asp:Menu ID="MainMenu" runat="server" ... />
</nav>
</header>
<main>
<asp:ContentPlaceHolder ID="MainContent" runat="server" />
</main>
<footer>© <%: DateTime.Now.Year %></footer>
</form>
</body>
</html>
Blazor Layout Equivalent
@inherits LayoutComponentBase
<PageTitle>@PageTitle</PageTitle>
<HeadContent>
@HeadContent
</HeadContent>
<header>
<nav>
<Menu ... />
</nav>
</header>
<main>
@Body
</main>
<footer>© @DateTime.Now.Year</footer>
Key changes:
<%@ Master %>directive →@inherits LayoutComponentBase<form runat="server">→ removed entirely<asp:ContentPlaceHolder ID="MainContent">→@Body<asp:ContentPlaceHolder ID="HeadContent">→ Handled by<HeadContent>in child pages<asp:ScriptManager>→<ScriptManager />(renders nothing — correct)<%: expression %>→@expression
Common Gotchas
blazor.web.js 404 (No Interactivity)
If pages render as static HTML but nothing is interactive (buttons don't click, forms don't submit), check:
_framework/blazor.web.jsis returning 404 (check browser DevTools console)- Fix: Use
app.MapStaticAssets()inProgram.csinstead ofapp.UseStaticFiles() - Fix: Ensure
Properties/launchSettings.jsonsetsASPNETCORE_ENVIRONMENT=Development
No ViewState
Blazor components maintain their own state in fields/properties. There is no ViewState dictionary. If code reads/writes ViewState["key"], replace with a component field.
No PostBack
There is no IsPostBack. Code in Page_Load that checks if (!IsPostBack) should move to OnInitializedAsync() (which runs once on first load).
No DataSource Controls
<asp:SqlDataSource>, <asp:ObjectDataSource>, <asp:EntityDataSource> have no BWFC equivalents. Replace with injected services that load data in OnInitializedAsync().
ID Rendering
Web Forms generates client IDs like ctl00_MainContent_GridView1. Blazor doesn't render component IDs into HTML. If CSS or JavaScript targets these IDs, use CssClass instead or add explicit id attributes.
Template Context Variable
In Web Forms, Item is implicitly available in templates. In BWFC, use Context="Item" on template elements:
<ItemTemplate Context="Item">
@Item.PropertyName
</ItemTemplate>
runat="server" on HTML Elements
Some Web Forms pages add runat="server" to plain HTML elements (e.g., <div runat="server">). Remove runat="server" — use @ref if the element needs programmatic access.
String Format in Binding
<!-- Web Forms -->
<%#: string.Format("{0:C}", Item.Price) %>
<!-- Blazor -->
@Item.Price.ToString("C")
Visibility Pattern
<!-- Web Forms -->
<asp:Panel Visible="false" runat="server">...</asp:Panel>
<!-- Blazor option 1: BWFC Visible parameter -->
<Panel Visible="false">...</Panel>
<!-- Blazor option 2: Razor conditional (preferred for dynamic) -->
@if (showPanel)
{
<Panel>...</Panel>
}
Nested Master Pages
Web Forms supports nested Master Pages. In Blazor, use nested layouts:
@* ChildLayout.razor *@
@inherits LayoutComponentBase
@layout MainLayout
<div class="child-wrapper">
@Body
</div>
Attributes Removed During Migration
These Web Forms attributes have no Blazor equivalent and should be silently removed:
runat="server"— always removeAutoEventWireup="true"— no equivalentCodeBehind="X.aspx.cs"— no equivalent (use.razor.csconvention)CodeFile="X.aspx.cs"— same as CodeBehindInherits="Namespace.Class"— use@inheritsonly if neededEnableViewState="false"— no ViewState in BlazorViewStateMode="Disabled"— no ViewState in BlazorValidateRequest="false"— no request validation in BlazorMaintainScrollPositionOnPostBack="true"— no postback in BlazorClientIDMode="Static"— no client ID munging in BlazorEnableTheming="false"— not applicableSkinID="X"— use BWFC theming system if needed
BWFC Component Coverage
52 components across 7 categories:
| Category | Components |
|---|---|
| Editor Controls | AdRotator, BulletedList, Button, Calendar, CheckBox, CheckBoxList, DropDownList, FileUpload, HiddenField, HyperLink, Image, ImageButton, Label, LinkButton, ListBox, Literal, Localize, MultiView, Panel, PlaceHolder, RadioButton, RadioButtonList, Table, TextBox, View |
| Data Controls | DataGrid, DataList, DataPager, DetailsView, FormView, GridView, ListView, Repeater |
| Validation Controls | CompareValidator, CustomValidator, ModelErrorMessage, RangeValidator, RegularExpressionValidator, RequiredFieldValidator, ValidationSummary |
| Navigation Controls | Menu, SiteMapPath, TreeView |
| Login Controls | ChangePassword, CreateUserWizard, Login, LoginName, LoginStatus, LoginView, PasswordRecovery |
| AJAX Controls | ScriptManager, ScriptManagerProxy, Timer, UpdatePanel, UpdateProgress |
WingtipToys-Specific Patterns
The WingtipToys canonical demo (2013) uses these specific patterns that BWFC fully supports:
| WingtipToys Pattern | BWFC Migration |
|---|---|
ItemType="WingtipToys.Models.Product" |
TItem="Product" |
SelectMethod="GetProducts" |
Items="products" (load in OnInitializedAsync) |
<asp:ListView> with LayoutTemplate/GroupTemplate |
<ListView> — templates preserved |
<asp:FormView> with RenderOuterTable="false" |
<FormView RenderOuterTable="false"> ✅ |
<asp:GridView> with BoundField + TemplateField |
Same column types in BWFC |
| ASP.NET Identity login/register | Migrate to Blazor Identity (separate from BWFC) |
Shopping cart with Session state |
Replace with scoped service + ProtectedSessionStorage |
~/ route prefix |
Replace with / |
<asp:ModelErrorMessage> |
<ModelErrorMessage> ✅ |
Files to Create During Migration
For a typical Web Forms → Blazor migration, create these files:
Program.cs— Service registration, middleware pipelineProperties/launchSettings.json— Environment config fordotnet runApp.razor— Root component with Router_Imports.razor— Global usings including BWFC namespacesComponents/Layout/MainLayout.razor— From Master PageComponents/Pages/*.razor— One per .aspx pageServices/*.cs— Replace DataSource controls and code-behind data methodsModels/*.cs— Copy/migrate from Web Forms project (often .NET Standard already)
Identity & Authentication Migration
ASP.NET Web Forms Identity uses SignInManager and forms authentication cookies, which require an active HTTP response. In Blazor Server (SignalR), there is no HTTP response after the initial page load. This requires a different pattern.
Authentication Endpoints
Authentication operations (login, logout, register) must be HTTP endpoints, NOT Blazor component handlers:
// Program.cs — HTTP endpoints for auth operations
// Login: SignInManager needs HTTP context to set auth cookies
app.MapGet("/Account/PerformLogin", async (
string email, string password,
SignInManager<IdentityUser> signInManager) =>
{
var result = await signInManager.PasswordSignInAsync(email, password,
isPersistent: false, lockoutOnFailure: false);
if (result.Succeeded)
return Results.Redirect("/");
return Results.Redirect("/Account/Login?error=" +
Uri.EscapeDataString("Invalid login attempt."));
});
// Logout: must be POST for CSRF protection
app.MapPost("/Account/PerformLogout", async (
SignInManager<IdentityUser> signInManager) =>
{
await signInManager.SignOutAsync();
return Results.Redirect("/");
});
Login Page Pattern
The Login page is an InteractiveServer component that collects credentials, then navigates to the HTTP endpoint with forceLoad: true:
private void HandleLogin(MouseEventArgs args)
{
var loginUrl = $"/Account/PerformLogin?email={Uri.EscapeDataString(email)}" +
$"&password={Uri.EscapeDataString(password)}";
NavigationManager.NavigateTo(loginUrl, forceLoad: true);
}
Logout Form Pattern
The logout button must be a plain HTML form (not a Blazor form) that posts to the HTTP endpoint. Use data-enhance="false" to prevent Blazor enhanced navigation from intercepting:
<form method="post" action="/Account/PerformLogout" data-enhance="false">
<AntiforgeryToken />
<button type="submit" class="btn btn-link">Log off</button>
</form>
⚠️ CRITICAL: Without
data-enhance="false", Blazor intercepts the form POST and tries to handle it as a Blazor form submission, which fails because there's no matching@formnamehandler.
AuthorizeView in Layout
Replace Web Forms <asp:LoginView> with <AuthorizeView>:
<AuthorizeView>
<Authorized>
<li><a href="/Account/Manage">Hello, @context.User.Identity?.Name!</a></li>
<li>
<form method="post" action="/Account/PerformLogout" data-enhance="false">
<AntiforgeryToken />
<button type="submit" class="btn btn-link">Log off</button>
</form>
</li>
</Authorized>
<NotAuthorized>
<li><a href="/Account/Register">Register</a></li>
<li><a href="/Account/Login">Log in</a></li>
</NotAuthorized>
</AuthorizeView>