When a WebForms slice is rebuilt on Blazor, most of the work is not exotic. It is a long series of small translations: this control becomes that component, this life-cycle event becomes that method, this page-level trick becomes state the component owns. Having the map in your head before you start keeps the rewrite mechanical where it can be mechanical, and saves the thinking for the parts that deserve it.
This is the map we use. It assumes Blazor on .NET 8 with interactive server rendering, which is the closest operational match to how a WebForms app already behaves: state and rendering live on the server, and UI events round-trip to it. Microsoft's free e-book Blazor for ASP.NET Web Forms Developers covers the same ground chapter by chapter and is worth having open beside this.
The control map
| WebForms | Blazor equivalent | Notes |
|---|---|---|
.aspx page | Routable component (@page) | One page becomes one .razor file with a route template. |
| Master page | Layout component | Inherit LayoutComponentBase; ContentPlaceHolder becomes @Body. |
User control (.ascx) | Component | [Parameter] properties replace the control's public properties. |
Code-behind (.aspx.cs) | @code block or partial class | A partial class keeps the file layout familiar during migration. |
GridView | QuickGrid | The built-in QuickGrid component covers sorting, paging and templated columns. |
Repeater / ListView | @foreach over a collection | The pattern collapses to a loop with markup; no control needed. |
TextBox, DropDownList, CheckBox | InputText, InputSelect, InputCheckbox | Inside an EditForm, bound with @bind-Value. |
| Validators | Data annotations plus ValidationMessage | Rules move off the markup and onto the model. |
Page_Load | OnInitializedAsync / OnParametersSetAsync | There is no IsPostBack; initialization runs per navigation, not per event. |
| ViewState | Component fields | State is ordinary fields on the component for the circuit's lifetime. |
Session | Scoped services | Decide deliberately what survives; do not recreate a session grab-bag. |
UpdatePanel | Nothing | Interactive server rendering already does partial updates. |
Response.Redirect | NavigationManager.NavigateTo | An injected service, without the ThreadAbortException folklore. |
Three of those rows do most of the work in a typical line-of-business app: pages, grids, and forms.
Worked example: a grid page
The WebForms version
A screen most of these systems have somewhere, declared in markup with paging events handled in code-behind:
<asp:GridView ID="gvOrders" runat="server" AutoGenerateColumns="false"
AllowPaging="true" PageSize="25"
OnPageIndexChanging="gvOrders_PageIndexChanging">
<Columns>
<asp:BoundField DataField="OrderNumber" HeaderText="Order #" />
<asp:BoundField DataField="CustomerName" HeaderText="Customer" />
<asp:BoundField DataField="Total" HeaderText="Total" DataFormatString="{0:C}" />
</Columns>
</asp:GridView>
The code-behind rebinds the grid on every postback, guarded by IsPostBack, with the current page index stored in ViewState.
The Blazor version
@page "/orders"
@inject IOrderQueries Orders
<QuickGrid ItemsProvider="LoadOrders" Pagination="pagination">
<PropertyColumn Property="@(o => o.OrderNumber)" Title="Order #" Sortable="true" />
<PropertyColumn Property="@(o => o.CustomerName)" Title="Customer" Sortable="true" />
<PropertyColumn Property="@(o => o.Total)" Format="C" Title="Total" />
</QuickGrid>
<Paginator State="pagination" />
@code {
private readonly PaginationState pagination = new() { ItemsPerPage = 25 };
private async ValueTask<GridItemsProviderResult<OrderRow>> LoadOrders(
GridItemsProviderRequest<OrderRow> request)
{
var page = await Orders.GetPageAsync(request.StartIndex, request.Count ?? 25);
return GridItemsProviderResult.From(page.Rows, page.TotalCount);
}
}
The binding events, the IsPostBack guard and the ViewState payload are gone; paging state lives in pagination, and data access is an injected service you can test on its own. This is also the moment to pull the query out of the page. WebForms code-behind usually talks to the database directly, and moving that into IOrderQueries is what makes the next page that needs the same data cheap.
What does not map
- Page life-cycle tricks. Code that depends on the exact ordering of
Init,LoadandPreRenderacross nested controls has no equivalent, because the problem it solved no longer exists. Rewrite the intent, not the mechanism. - Third-party control suites. A 2009-era grid or scheduler control has no drop-in twin. Each one is a per-control decision: the vendor's Blazor successor, a built-in component, or plain HTML. Characterize the behavior before swapping anything.
HttpContext.Currentreached from business logic. Blazor components do not process a request per interaction, so code that grabs the current user or query string from deep inside a class library needs those values passed in. Tedious, and one of the better things the migration forces.
Where to start
Not with the map. Start with characterization tests on the pages you are about to move, then translate with the map, then make the harness agree; we walked through a workable harness in golden-master tests for legacy web apps. The map makes the translation fast. The harness makes it safe.