Defines the canonical target architecture, BWFC control preservation rules, and migration patterns for converting ASP.NET Web Forms applications to Blazor Server on .NET 10. Covers WebFormsPageBase setup, render mode placement, database and identity migration, data control strategy, static asset relocation, and ListView template placeholder conversion. Use when running the bwfc-migrate.ps1 script, performing manual Layer 2 migration, debugging missing controls or broken page titles, or configuring _Imports.razor and Program.cs for a BWFC project.
When migrating an ASP.NET Web Forms application to Blazor using BlazorWebFormsComponents, these standards define the canonical target architecture, tooling choices, and migration patterns. Established through five WingtipToys migration benchmark runs and codified as a directive by Jeffrey T. Fritz.
Apply these standards to:
bwfc-migrate.ps1) enhancements| Setting | Standard |
|---|---|
| Framework | .NET 10 (or latest LTS/.NET preview) |
| Project template | dotnet new blazor --interactivity Server |
| Render mode | Global Server Interactive |
| Base class |
WebFormsPageBase for pages (@inherits in _Imports.razor); ComponentBase for non-page components |
| Service registration | builder.Services.AddBlazorWebFormsComponents() in Program.cs |
| Layout | MainLayout.razor with @inherits LayoutComponentBase and @Body |
@rendermodeis a directive attribute, not a standalone directive. It goes on component instances in markup, not in_Imports.razor.
_Imports.razor — add the static using so you can write InteractiveServer instead of RenderMode.InteractiveServer:
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@inherits BlazorWebFormsComponents.WebFormsPageBase
App.razor — apply render mode to the top-level routable components:
<HeadOutlet @rendermode="InteractiveServer" />
...
<Routes @rendermode="InteractiveServer" />
Do not place @rendermode InteractiveServer as a line in _Imports.razor — it will cause build errors (RZ10003, CS0103, RZ10024).
WebFormsPageBase eliminates per-page boilerplate when migrating Web Forms code-behind. A single @inherits directive in _Imports.razor gives all pages access to familiar Web Forms properties.
One-time setup:
_Imports.razor — add @inherits BlazorWebFormsComponents.WebFormsPageBaseProgram.cs — add builder.Services.AddBlazorWebFormsComponents()MainLayout.razor) — add <BlazorWebFormsComponents.Page /> (renders <PageTitle> and <meta> tags)Properties available on every page:
| Property | Behavior |
|---|---|
Title | Delegates to IPageService.Title — Page.Title = "X" works unchanged |
MetaDescription | Delegates to IPageService.MetaDescription |
MetaKeywords | Delegates to IPageService.MetaKeywords |
IsPostBack | SSR GET → false, SSR POST → true, Interactive first render → false, subsequent → true |
Page | Returns this — enables Page.Title = "X" dot syntax |
Request | RequestShim — Request.QueryString, Request.Cookies, Request.Url, Request.Form |
Response | ResponseShim — Response.Redirect() (auto-strips ~/ and .aspx), Response.Cookies |
Server | ServerShim — Server.MapPath(), Server.HtmlEncode(), Server.UrlEncode() |
Session | SessionShim — Session["key"] indexer, .Get<T>(), .Remove(), .Clear() |
Cache | CacheShim — Cache["key"] indexer, Cache.Insert(), Cache.Remove() |
ViewState | ViewStateDictionary — per-component ViewState["key"] indexer |
ClientScript | ClientScriptShim — RegisterStartupScript(), GetPostBackEventReference() |
PostBack event | EventHandler<PostBackEventArgs> — raised when __doPostBack() fires |
When to still use @inject IPageService: Non-page components (e.g., a shared header) that need access to page metadata should inject IPageService directly.
Microsoft.EntityFrameworkCore (10.0.3), .SqlServer / .Sqlite, .Tools, .DesignDropCreateDatabaseIfModelChanges with EnsureCreated + idempotent seedIDbContextFactory<T> or scoped DbContext injectiondotnet aspnet-codegenerator identity for scaffoldingSignInManager / UserManager APIs change — full subsystem replacementBWFC components already expose EventCallback parameters with matching Web Forms names:
| Web Forms | BWFC | Action |
|---|---|---|
OnClick="Handler" | OnClick (EventCallback<MouseEventArgs>) | Preserve attribute verbatim — only update handler signature |
OnCommand="Handler" | OnCommand (EventCallback<CommandEventArgs>) | Preserve, update signature |
OnSelectedIndexChanged="Handler" | OnSelectedIndexChanged (EventCallback<ChangeEventArgs>) | Preserve, update signature |
OnTextChanged="Handler" | OnTextChanged (EventCallback<ChangeEventArgs>) | Preserve, update signature |
OnCheckedChanged="Handler" | OnCheckedChanged (EventCallback<ChangeEventArgs>) | Preserve, update signature |
Signature change pattern:
// Web Forms
protected void Button1_Click(object sender, EventArgs e) { ... }
// Blazor (BWFC)
private void Button1_Click(MouseEventArgs e) { ... }
// or
private async Task Button1_Click(MouseEventArgs e) { ... }
The script should preserve the attribute and annotate the signature change needed.
| Web Forms Control | BWFC Component | Use Instead Of |
|---|---|---|
<asp:ListView> | <ListView Items="@data"> with ItemTemplate | @foreach + HTML table |
<asp:GridView> | <GridView Items="@data"> with columns | @foreach + <table> |
<asp:FormView> | <FormView Items="@data"> with ItemTemplate | Direct HTML rendering |
<asp:Repeater> | <Repeater Items="@data"> with ItemTemplate | @foreach loops |
<asp:DetailsView> | <DetailsView Items="@data"> with fields | Manual field rendering |
<asp:DataList> | <DataList Items="@data"> with ItemTemplate | @foreach + grid HTML |
SelectMethod → Items: Replace SelectMethod="GetProducts" with Items="@_products" where _products is populated in OnInitializedAsync via an injected service or DbContext.
Session["key"] with a scoped DI serviceIHttpContextAccessor for cookie-based persistence when neededProgram.cs with builder.Services.AddScoped<TService>()Session["CartId"] → CartStateService with cookie-based cart IDwwwroot/BundleConfig.cs) → explicit <link> tags in App.razor<script> tags in App.razor~/Images/ → /Images/ (only for tilde-prefixed paths; see Static Asset Path Preservation below)CRITICAL RULE: When rewriting code-behind files during Layer 2, PRESERVE the source image/asset path structure.
wwwroot/./Catalog/Images/{name}.png, keep /Catalog/Images/{name}.png in Blazor templates.wwwroot/ are the source of truth. Check what paths exist BEFORE rewriting src attributes.Example of what NOT to do:
<img src="/Catalog/Images/Thumbs/car.png">wwwroot/Catalog/Images/Thumbs/car.png ✅<img src="/Images/Products/car.png"> ❌ (files don't exist there!)Correct approach:
wwwroot/ for actual file locationsAfter Layer 2 completes, verify that App.razor's <head> section contains <link> tags for CSS files. The Layer 1 script should auto-detect CSS files in wwwroot/Content/ and inject references, but verify this happened. If CSS links are missing, add them manually.
Bootstrap CSS is REQUIRED for proper navbar and layout styling. Missing CSS is a P0 failure.
| Web Forms | Blazor | Notes |
|---|---|---|
Page_Load | OnInitializedAsync | One-time init |
Page_PreInit | OnInitializedAsync (early) | Theme setup |
Page_PreRender | OnAfterRenderAsync | Post-render logic |
IsPostBack check | if (!IsPostBack) works AS-IS via WebFormsPageBase | SSR: checks HTTP method. Interactive: tracks render count. |
Page.Title | Page.Title = "X" works AS-IS via WebFormsPageBase | WebFormsPageBase delegates to IPageService. <BlazorWebFormsComponents.Page /> in layout renders <PageTitle> and <meta> tags. |
Response.Redirect | Response.Redirect("~/path") works AS-IS via ResponseShim | Auto-strips ~/ and .aspx. Uses NavigationManager.NavigateTo() internally. |
Request.Form | Request.Form["key"] works AS-IS via FormShim | Use <WebFormsForm OnSubmit="SetRequestFormData"> in interactive mode. |
Request.QueryString | Request.QueryString["key"] works AS-IS via RequestShim | Parses from NavigationManager.Uri. |
Session["key"] | Works AS-IS via SessionShim | In-memory fallback in interactive mode; syncs with ISession when available. |
Cache["key"] | Works AS-IS via CacheShim | Wraps IMemoryCache with expiration support. |
Server.MapPath | Works AS-IS via ServerShim | ~/ maps to wwwroot. |
Page.ClientScript | Works AS-IS via ClientScriptShim | Queues scripts, flushes via IJSRuntime. |
Script handles (Layer 1):
asp: prefix stripping (preserves BWFC tags)AuthenticationStateProvider natively)Always manual (Layer 2):
@* Web Forms *@
<asp:ListView ID="productList" runat="server"
DataKeyNames="ProductID" GroupItemCount="4"
ItemType="WingtipToys.Models.Product"
SelectMethod="GetProducts">
<ItemTemplate>
<td><%#: Item.ProductName %></td>
</ItemTemplate>
</asp:ListView>
@* After migration (BWFC preserved) *@
<ListView Items="@_products" GroupItemCount="4">
<ItemTemplate>
<td>@context.ProductName</td>
</ItemTemplate>
</ListView>
@code {
[Inject] private ProductContext Db { get; set; }
private List<Product> _products;
protected override async Task OnInitializedAsync()
{
_products = await Db.Products.ToListAsync();
}
}
@* Web Forms *@
<asp:Button ID="btnRemove" runat="server" Text="Remove"
OnClick="RemoveItem_Click" CommandArgument='<%# Item.ItemId %>' />
@* After migration (BWFC preserved) *@
<Button Text="Remove"
OnClick="RemoveItem_Click" CommandArgument="@context.ItemId" />
@code {
// Only signature changes — method name stays the same
private async Task RemoveItem_Click(MouseEventArgs e) { ... }
}
@* WRONG — loses all BWFC functionality *@
@foreach (var product in _products)
{
<tr>
<td>@product.ProductName</td>
</tr>
}
@* RIGHT — use BWFC ListView *@
<ListView Items="@_products">
<ItemTemplate>
<tr><td>@context.ProductName</td></tr>
</ItemTemplate>
</ListView>
@* WRONG — strips the handler, requires manual re-wiring *@
<Button Text="Submit" />
@* TODO: re-add click handler *@
@* RIGHT — preserve the attribute, only annotate signature change *@
<Button Text="Submit" OnClick="Submit_Click" />
@* TODO: Update Submit_Click signature: (object, EventArgs) → (MouseEventArgs) *@
// WRONG — Web Forms base class
public partial class ProductList : Page { }
// RIGHT — BWFC page base class (provides Page.Title, IsPostBack, etc.)
// Set via @inherits WebFormsPageBase in _Imports.razor
public partial class ProductList : WebFormsPageBase { }
// ALSO RIGHT — for non-page components
public partial class MyComponent : ComponentBase { }
Copy ALL of these from the Web Forms source to wwwroot/ in the Blazor project:
| Source Folder | Destination | Contains |
|---|---|---|
Content/ | wwwroot/Content/ | CSS files (bootstrap, Site.css, etc.) |
Scripts/ | wwwroot/Scripts/ | JavaScript (jQuery, Bootstrap JS, app scripts) |
Images/ | wwwroot/Images/ | Site images, logos |
Catalog/ (or similar) | wwwroot/Catalog/ | Product/content images |
fonts/ | wwwroot/fonts/ | Web fonts |
favicon.ico | wwwroot/favicon.ico | Favicon |
Verification checklist:
<link> CSS references in App.razor have matching physical files in wwwroot<script> JS references in App.razor have matching physical files in wwwroot<img> src paths in pages have matching physical files in wwwrootCommon miss: The Scripts/ folder is easy to forget because CSS is more visually
obvious when missing. Always verify JS files are present — jQuery and Bootstrap JS are
needed for interactive Bootstrap features (dropdowns, modals, collapse).
In ASP.NET Web Forms, LayoutTemplate and GroupTemplate use placeholder elements — HTML elements with specific IDs that the runtime replaces with rendered content:
<asp:ListView ...>
<LayoutTemplate>
<table>
<tr id="groupPlaceholder"></tr> ← Runtime replaces this with groups
</table>
</LayoutTemplate>
<GroupTemplate>
<tr>
<td id="itemPlaceholder"></td> ← Runtime replaces this with items
</tr>
</GroupTemplate>
</asp:ListView>
In BWFC Blazor, LayoutTemplate and GroupTemplate are RenderFragment<RenderFragment>
parameters. The child content is passed as @context — you must explicitly render it:
<ListView Items="@Products" GroupItemCount="4" TItem="Product">
<LayoutTemplate>
<table>
@context @* ← This IS the rendered groups *@
</table>
</LayoutTemplate>
<GroupTemplate>
<tr>
@context @* ← This IS the rendered items *@
</tr>
</GroupTemplate>
<ItemTemplate>
<td>@context.ProductName</td>
</ItemTemplate>
</ListView>
*Replace any element with an ID containing "Placeholder" inside a Template block
with @context. The placeholder element is just a marker — @context is the
actual rendered content.
| Web Forms Pattern | Blazor Pattern |
|---|---|
<tr id="groupPlaceholder"></tr> | @context |
<td id="itemPlaceholder"></td> | @context |
<div id="groupPlaceholder" /> | @context |
If the ListView doesn't use GroupItemCount, LayoutTemplate still needs @context:
<ListView Items="@Items" TItem="MyItem">
<LayoutTemplate>
<ul>
@context @* Items render here *@
</ul>
</LayoutTemplate>
<ItemTemplate>
<li>@context.Name</li>
</ItemTemplate>
</ListView>
If a ListView renders its LayoutTemplate structure but shows no items, the most
likely cause is a missing @context in LayoutTemplate or GroupTemplate.
When migrating detail pages (ProductDetails, FormView, etc.), ensure action links
are preserved. The script converts <asp:HyperLink> but may lose context about
the link's purpose.
Common action links to verify after migration:
<a href="/[email protected]">Add To Cart</a><a href="/[email protected]">Edit</a>Verify: After Layer 1 (script) conversion, check that all action links from the
original page survive in the converted output. If any are missing, add them manually
in Layer 2 using the @context.PropertyName syntax for data-bound values.
For applications using the Ajax Control Toolkit (<ajaxToolkit:*> controls), see the companion document:
Ajax Control Toolkit Migration Standards
This document describes:
bwfc-migrate.ps1) handles ACT controls and ToolkitScriptManager@using, render mode)Link to full component documentation: