If you've inherited a WebForms application and been tasked with modernizing it to Blazor, you already know this isn't a simple find-and-replace job. The two frameworks share a common ancestor in ASP.NET, but their execution models are fundamentally different.
This post walks through the core technical challenges you'll encounter and the patterns that actually work for solving them. We'll look at real code transformations, architectural decisions, and the gotchas that will bite you if you're not prepared.
Before diving into specific patterns, it's worth understanding why WebForms-to-Blazor migration is more complex than it appears.
WebForms was built around a stateful abstraction over HTTP's stateless nature:
Request → Page Init → Load ViewState → Process PostBack → Save ViewState → Render → Response
Every control participates in this lifecycle. ViewState persists control state between requests. PostBacks trigger server-side event handlers. The page acts as a container managing the entire control tree.
Blazor Server operates differently:
Initial Request → Render Component Tree → Establish SignalR Connection → Handle Events via SignalR → Re-render Affected Components
There's no ViewState. State lives in memory on the server, tied to the SignalR circuit. Components are more independent—they manage their own lifecycle and can re-render without affecting siblings.
Blazor WebAssembly is different again—the entire runtime executes client-side, with no persistent server connection.
These aren't just implementation details. They fundamentally change how you structure code.
ViewState is usually the first migration challenge teams encounter. Consider this typical WebForms pattern:
// WebForms - Default.aspx.cs public partial class CustomerList : System.Web.UI.Page { protected void Page_Load(object sender, EventArgs e) { if (!IsPostBack) { ViewState["SortColumn"] = "Name"; ViewState["SortDirection"] = "ASC"; BindGrid(); } } protected void gvCustomers_Sorting(object sender, GridViewSortEventArgs e) { string currentDirection = ViewState["SortDirection"].ToString(); ViewState["SortColumn"] = e.SortExpression; ViewState["SortDirection"] = currentDirection == "ASC" ? "DESC" : "ASC"; BindGrid(); } private void BindGrid() { var customers = GetCustomers( ViewState["SortColumn"].ToString(), ViewState["SortDirection"].ToString() ); gvCustomers.DataSource = customers; gvCustomers.DataBind(); } }
The Blazor equivalent uses component parameters and fields:
// Blazor - CustomerList.razor @page "/customers" @inject ICustomerService CustomerService <table class="table"> <thead> <tr> <th @onclick="() => Sort("Name")"> Name @(SortColumn == "Name" ? (SortDirection == "ASC" ? "↑" : "↓") : "") </th> <th @onclick="() => Sort("Email")"> Email @(SortColumn == "Email" ? (SortDirection == "ASC" ? "↑" : "↓") : "") </th> </tr> </thead> <tbody> @foreach (var customer in customers) { <tr> <td>@customer.Name</td> <td>@customer.Email</td> </tr> } </tbody> </table> @code { private List<Customer> customers = new(); private string SortColumn = "Name"; private string SortDirection = "ASC"; protected override async Task OnInitializedAsync() { await LoadCustomers(); } private async Task Sort(string column) { if (SortColumn == column) { SortDirection = SortDirection == "ASC" ? "DESC" : "ASC"; } else { SortColumn = column; SortDirection = "ASC"; } await LoadCustomers(); } private async Task LoadCustomers() { customers = await CustomerService.GetCustomersAsync(SortColumn, SortDirection); } }
Key differences to note:
ViewState dictionary, you have typed fields.OnInitializedAsync runs once when the component initializes. Re-renders don't re-run it.ViewState is scoped to a page. In Blazor, if you need state to survive component disposal (navigation), you have options:
// Option 1: Scoped service (survives within the circuit/session) public class CustomerFilterState { public string SortColumn { get; set; } = "Name"; public string SortDirection { get; set; } = "ASC"; public string SearchTerm { get; set; } = ""; } // Register in Program.cs builder.Services.AddScoped<CustomerFilterState>(); // Inject and use in component @inject CustomerFilterState FilterState
// Option 2: URL parameters (bookmarkable, shareable) @page "/customers/{SortColumn?}/{SortDirection?}" [Parameter] public string SortColumn { get; set; } = "Name"; [Parameter] public string SortDirection { get; set; } = "ASC";
// Option 3: Browser storage (persists across sessions) @inject ProtectedLocalStorage LocalStorage protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { var result = await LocalStorage.GetAsync<string>("sortColumn"); if (result.Success) { SortColumn = result.Value; } } }
Choose based on your requirements: scoped services for session state, URL parameters for shareable state, browser storage for persistence.
WebForms relies heavily on PostBack for user interactions. Here's a typical pattern:
// WebForms - OrderEntry.aspx <asp:DropDownList ID="ddlProduct" runat="server" AutoPostBack="true" OnSelectedIndexChanged="ddlProduct_SelectedIndexChanged" /> <asp:TextBox ID="txtQuantity" runat="server" /> <asp:Label ID="lblPrice" runat="server" /> <asp:Label ID="lblTotal" runat="server" /> <asp:Button ID="btnAddToCart" runat="server" Text="Add to Cart" OnClick="btnAddToCart_Click" />
// OrderEntry.aspx.cs protected void ddlProduct_SelectedIndexChanged(object sender, EventArgs e) { var product = GetProduct(ddlProduct.SelectedValue); lblPrice.Text = product.Price.ToString("C"); UpdateTotal(); } protected void btnAddToCart_Click(object sender, EventArgs e) { var item = new CartItem { ProductId = ddlProduct.SelectedValue, Quantity = int.Parse(txtQuantity.Text) }; AddToCart(item); Response.Redirect("Cart.aspx"); } private void UpdateTotal() { var price = decimal.Parse(lblPrice.Text, NumberStyles.Currency); var quantity = int.Parse(txtQuantity.Text); lblTotal.Text = (price * quantity).ToString("C"); }
In Blazor, this becomes more reactive:
// Blazor - OrderEntry.razor @page "/order" @inject IProductService ProductService @inject ICartService CartService @inject NavigationManager Navigation <div class="form-group"> <label>Product</label> <select class="form-control" @bind="SelectedProductId" @bind:after="OnProductChanged"> <option value="">Select a product...</option> @foreach (var product in products) { <option value="@product.Id">@product.Name</option> } </select> </div> <div class="form-group"> <label>Quantity</label> <input type="number" class="form-control" @bind="Quantity" @bind:event="oninput" @bind:after="UpdateTotal" min="1" /> </div> <div class="form-group"> <label>Price</label> <span>@Price.ToString("C")</span> </div> <div class="form-group"> <label>Total</label> <span>@Total.ToString("C")</span> </div> <button class="btn btn-primary" @onclick="AddToCart" disabled="@(!CanAddToCart)"> Add to Cart </button> @code { private List<Product> products = new(); private string SelectedProductId = ""; private int Quantity = 1; private decimal Price; private decimal Total; private bool CanAddToCart => !string.IsNullOrEmpty(SelectedProductId) && Quantity > 0; protected override async Task OnInitializedAsync() { products = await ProductService.GetProductsAsync(); } private async Task OnProductChanged() { if (!string.IsNullOrEmpty(SelectedProductId)) { var product = await ProductService.GetProductAsync(SelectedProductId); Price = product.Price; UpdateTotal(); } } private void UpdateTotal() { Total = Price * Quantity; } private async Task AddToCart() { await CartService.AddItemAsync(new CartItem { ProductId = SelectedProductId, Quantity = Quantity }); Navigation.NavigateTo("/cart"); } }
Critical differences:
@bind. The @bind:after modifier lets you run logic after binding completes.CanAddToCart automatically reflects current state; no manual enable/disable logic.NavigationManager handles SPA-style navigation.GridView is one of the most-used WebForms controls, and there's no direct Blazor equivalent in the box. You have three options:
For basic display with sorting and paging, a hand-built component is often fine:
// DataGrid.razor - Reusable grid component @typeparam TItem <table class="table"> <thead> <tr> @foreach (var column in Columns) { <th @onclick="() => OnSort(column.Property)" style="cursor: pointer;"> @column.Title @if (SortProperty == column.Property) { <span>@(SortAscending ? "↑" : "↓")</span> } </th> } </tr> </thead> <tbody> @foreach (var item in PagedItems) { <tr> @foreach (var column in Columns) { <td>@column.ValueSelector(item)</td> } </tr> } </tbody> </table> <div class="pagination"> <button @onclick="PreviousPage" disabled="@(CurrentPage == 1)">Previous</button> <span>Page @CurrentPage of @TotalPages</span> <button @onclick="NextPage" disabled="@(CurrentPage == TotalPages)">Next</button> </div> @code { [Parameter] public List<TItem> Items { get; set; } = new(); [Parameter] public List<ColumnDefinition<TItem>> Columns { get; set; } = new(); [Parameter] public int PageSize { get; set; } = 10; [Parameter] public EventCallback<SortEventArgs> OnSortChanged { get; set; } private int CurrentPage = 1; private string SortProperty = ""; private bool SortAscending = true; private int TotalPages => (int)Math.Ceiling(Items.Count / (double)PageSize); private IEnumerable<TItem> PagedItems => Items .Skip((CurrentPage - 1) * PageSize) .Take(PageSize); private async Task OnSort(string property) { if (SortProperty == property) { SortAscending = !SortAscending; } else { SortProperty = property; SortAscending = true; } await OnSortChanged.InvokeAsync(new SortEventArgs(SortProperty, SortAscending)); } private void PreviousPage() => CurrentPage = Math.Max(1, CurrentPage - 1); private void NextPage() => CurrentPage = Math.Min(TotalPages, CurrentPage + 1); } public class ColumnDefinition<TItem> { public string Property { get; set; } public string Title { get; set; } public Func<TItem, object> ValueSelector { get; set; } }
For advanced features like inline editing, filtering, grouping, and Excel export, consider commercial libraries:
The migration from WebForms GridView to these libraries is more straightforward since they have similar feature sets.
For .NET 10, Microsoft's QuickGrid is a lightweight option:
@using Microsoft.AspNetCore.Components.QuickGrid <QuickGrid Items="@customers.AsQueryable()" Pagination="@pagination"> <PropertyColumn Property="@(c => c.Name)" Sortable="true" /> <PropertyColumn Property="@(c => c.Email)" Sortable="true" /> <TemplateColumn Title="Actions"> <button @onclick="() => Edit(context)">Edit</button> </TemplateColumn> </QuickGrid> <Paginator State="@pagination" /> @code { private PaginationState pagination = new() { ItemsPerPage = 10 }; }
UpdatePanel was WebForms' answer to partial page updates without full PostBacks. Blazor handles this naturally and components re-render independently.
// WebForms - Using UpdatePanel for partial updates <asp:UpdatePanel ID="upSearch" runat="server"> <ContentTemplate> <asp:TextBox ID="txtSearch" runat="server" /> <asp:Button ID="btnSearch" runat="server" Text="Search" OnClick="btnSearch_Click" /> <asp:GridView ID="gvResults" runat="server" /> </ContentTemplate> <Triggers> <asp:AsyncPostBackTrigger ControlID="btnSearch" EventName="Click" /> </Triggers> </asp:UpdatePanel>
In Blazor, you don't need to think about this because each component manages its own rendering:
// Blazor - Partial rendering is automatic <div class="search-panel"> <input type="text" @bind="searchTerm" @bind:event="oninput" /> <button @onclick="Search">Search</button> </div> @if (isLoading) { <p>Searching...</p> } else if (results.Any()) { <ResultsGrid Items="results" /> }
For expensive operations, you can optimize with ShouldRender()
@code { private string lastSearchTerm = ""; protected override bool ShouldRender() { // Only re-render if search term actually changed if (searchTerm == lastSearchTerm) return false; lastSearchTerm = searchTerm; return true; } }
WebForms enforced a .aspx / .aspx.cs separation. Blazor supports this too, which can make migration easier:
// CustomerList.razor @page "/customers" @inherits CustomerListBase <h1>Customers</h1> <!-- markup here -->
// CustomerList.razor.cs public class CustomerListBase : ComponentBase { [Inject] protected ICustomerService CustomerService { get; set; } protected List<Customer> Customers { get; set; } = new(); protected override async Task OnInitializedAsync() { Customers = await CustomerService.GetCustomersAsync(); } }
This pattern is helpful during migration because it maintains the separation your team is used to. Over time, you might consolidate simpler components into single files.
WebForms Session doesn't exist in Blazor. For Blazor Server, use scoped services (they're already per-circuit). For user data that must survive disconnects, use database persistence or distributed cache.
// Instead of Session["UserId"] public class UserSessionService { public string UserId { get; set; } public List<string> RecentSearches { get; } = new(); } // Register as scoped builder.Services.AddScoped<UserSessionService>();
// WebForms Response.Redirect("~/Orders/Details.aspx?id=" + orderId); // Blazor Navigation.NavigateTo($"/orders/details/{orderId}");
WebForms validation controls don't translate directly. Use Blazor's EditForm with DataAnnotationsValidator:
<EditForm Model="@customer" OnValidSubmit="@HandleSubmit"> <DataAnnotationsValidator /> <ValidationSummary /> <div class="form-group"> <label>Name</label> <InputText @bind-Value="customer.Name" class="form-control" /> <ValidationMessage For="@(() => customer.Name)" /> </div> <button type="submit">Save</button> </EditForm> @code { private Customer customer = new(); private async Task HandleSubmit() { // Only called if validation passes await CustomerService.SaveAsync(customer); } }
If your WebForms code uses FindControl to locate controls dynamically, you'll need to rethink the approach. Blazor uses component references:
// Blazor - Component reference <MyComponent @ref="myComponentRef" /> @code { private MyComponent myComponentRef; private void DoSomething() { myComponentRef.SomeMethod(); } }
The WebForms lifecycle (Init, Load, PreRender, etc.) maps roughly to Blazor's component lifecycle, but not exactly:
| WebForms | Blazor Equivalent |
|---|---|
| Page_Init | SetParametersAsync (before) |
| Page_Load (!IsPostBack) | OnInitialized / OnInitializedAsync |
| Page_Load (IsPostBack) | OnParametersSet / OnParametersSetAsync |
| Page_PreRender | OnAfterRender / OnAfterRenderAsync |
The biggest favor you can do for your migration (and your future self) is extracting business logic from code-behind files before or during migration:
// Before: Logic embedded in code-behind protected void btnCalculate_Click(object sender, EventArgs e) { decimal subtotal = 0; foreach (var item in cartItems) { subtotal += item.Price * item.Quantity; } var tax = subtotal * 0.08m; var shipping = subtotal > 100 ? 0 : 9.99m; lblTotal.Text = (subtotal + tax + shipping).ToString("C"); } // After: Logic in service public class OrderCalculationService { public OrderTotals Calculate(IEnumerable<CartItem> items) { var subtotal = items.Sum(i => i.Price * i.Quantity); return new OrderTotals { Subtotal = subtotal, Tax = subtotal * 0.08m, Shipping = subtotal > 100 ? 0 : 9.99m }; } }
This logic now works identically in WebForms, Blazor, or an API controller. It's also testable.
Decide upfront how you'll handle:
Mixing these inconsistently leads to bugs that are hard to diagnose.
WebForms to Blazor migration is achievable, but it's not a simple syntax conversion. The frameworks have different philosophies about state, rendering, and component interaction. Understanding these differences upfront saves an enormous amount of debugging time.
The patterns here cover the most common scenarios, but every application has its quirks. Complex custom controls, heavy use of third-party WebForms components, and deeply embedded business logic all add complexity.
At GAPVelocity AI, we use AI to automate the mechanical conversion work including the syntax transformation, file restructuring, and pattern mapping. That handles the bulk of the codebase. But the architectural decisions, business logic validation, and production hardening still need experienced engineers who understand both frameworks.
If you're planning a migration and want to talk through your specific technical challenges, reach out. We've seen enough migrations to know all the ways they can go wrong - and more importantly how they can be 100% successful.
Dee Dee Walsh is a .NET dork from way back having served on the original Visual Basic product team plus worked on building and launching .NET and Visual Studio. She continues to work closely with the .NET community especially with the .NET Foundation.