Migrate ASP.NET Web Forms applications to Blazor Server using the webforms-to-blazor CLI tool and BlazorWebFormsComponents (BWFC). Orchestrates L1 automated transforms via CLI, then guides L2 contextual transforms. WHEN: "migrate aspx", "convert web forms", "web forms to blazor", "run migration". INVOKES: webforms-to-blazor CLI tool. FOR SINGLE OPERATIONS: use /bwfc-identity-migration for auth, /bwfc-data-migration for EF/architecture.
79
81%
Does it follow best practices?
Impact
63%
1.23xAverage score across 3 eval scenarios
Advisory
Suggest reviewing before use
This skill orchestrates the full migration from ASP.NET Web Forms to Blazor Server using a three-layer architecture:
| Layer | Executor | Coverage | Description |
|---|---|---|---|
| L1: Deterministic | webforms-to-blazor CLI tool | ~70% | 27 compiled transforms (16 markup + 11 code-behind), project scaffolding, config migration |
| L2: Contextual | Copilot (this skill) | ~15–20% | TODO-driven transforms requiring semantic understanding — session state, lifecycle, data binding |
| L3: Architectural | Developer | ~10–15% | Business logic, custom controls, auth flows, architectural decisions |
The CLI tool emits structured // TODO(bwfc-*) comments and a JSON migration report. L2 reads that report and applies contextual transforms per TODO category.
Three-layer migration architecture:
dotnet tool install -g Fritz.WebFormsToBlazorALWAYS inherit from WebFormsPageBase (via _Imports.razor) and use the Web Forms shims. The BlazorWebFormsComponents library provides shims that make Web Forms patterns work AS-IS in Blazor — no manual rewrites needed.
<asp:ListView>, <asp:FormView>, <asp:GridView>, <asp:DataList>, or <asp:Repeater> with manual HTML, hand-built <table> markup, or @foreach loops.<ListView>, <FormView>, <GridView>, <DataList>, <Repeater>.| Web Forms Pattern | Shim | Works In Interactive Mode? | Notes |
|---|---|---|---|
Response.Redirect("url") | ResponseShim | ✅ Yes | Uses NavigationManager internally, strips ~/ and .aspx |
Request.QueryString["key"] | RequestShim | ✅ Yes | Parses from NavigationManager.Uri |
Request.Cookies["key"] | RequestShim | ⚠️ SSR only | Returns empty in interactive, logs warning |
Request.Form["key"] | FormShim | ✅ Yes | Via WebFormsForm component in interactive mode |
Session["key"] get/set | SessionShim | ✅ Yes | In-memory ConcurrentDictionary per circuit |
Session.Get<T>("key") | SessionShim | ✅ Yes | Strongly-typed session access |
Server.MapPath("~/path") | ServerShim | ✅ Yes | Maps to web root path |
Server.HtmlEncode(text) | ServerShim | ✅ Yes | HTML encoding helper |
Cache["key"] get/set | CacheShim | ✅ Yes | Backed by IMemoryCache |
Page.Title | WebFormsPageBase | ✅ Yes | Sets page title |
Page.IsPostBack | WebFormsPageBase | ✅ Yes | Always false in Blazor (no postbacks) |
ClientScript.RegisterStartupScript() | ClientScriptShim | ✅ Yes | Injects JavaScript via JSRuntime |
ViewState["key"] | WebFormsPageBase | ✅ Yes | In-memory dictionary per component instance |
These Server.* methods have no BWFC shim and require manual rewriting:
| Web Forms Pattern | Shim? | Migration Action |
|---|---|---|
Server.Transfer("page.aspx") | ❌ None | Replace with NavigationManager.NavigateTo(). Server.Transfer does server-side URL rewriting which doesn't exist in Blazor. |
Server.GetLastError() | ❌ None | Use ILogger and middleware-based error handling (app.UseExceptionHandler). |
Server.ClearError() | ❌ None | Error clearing is handled by middleware in ASP.NET Core. |
HttpContext.Current.Session["key"] | ❌ None | Replace with Session["key"] (on pages) or inject SessionShim via constructor DI (non-page classes). The CLI tool handles this automatically. |
Classes that use Session["key"], Response.Redirect(), etc. but do NOT inherit from WebFormsPageBase must receive shims via constructor DI, not the base class:
// Non-page class — inject shims via DI
public class CartHelper
{
private readonly SessionShim _session;
public CartHelper(SessionShim session) => _session = session;
public string GetCartId() => _session["CartId"]?.ToString();
}Web Forms throws ThreadAbortException when Response.Redirect(url, true) is called with endResponse=true. Blazor does not throw this exception. Any catch (ThreadAbortException) blocks become dead code after migration — review and remove them.
builder.Services.AddBlazorWebFormsComponents() registers all shims automaticallyThe _Imports.razor file includes @inherits BlazorWebFormsComponents.WebFormsPageBase, which gives EVERY migrated page access to:
// Available on ALL pages via WebFormsPageBase:
Response.Redirect("/Products"); // ✅ Works
Session["CartId"] = 123; // ✅ Works
var param = Request.QueryString["id"]; // ✅ Works
var path = Server.MapPath("~/images"); // ✅ Works
Cache["Products"] = productList; // ✅ Works
ViewState["SortColumn"] = "Name"; // ✅ Works
ClientScript.RegisterStartupScript(...); // ✅ WorksNO INJECTION NEEDED. These properties are available directly in your @code block.
These are WRONG approaches that waste time. The shims already handle these patterns correctly.
// ❌ WRONG — Fighting Blazor's architecture
[Inject] IHttpContextAccessor HttpContextAccessor { get; set; }
var cookies = HttpContextAccessor.HttpContext?.Request.Cookies;✅ CORRECT — Use the RequestShim:
// ✅ Inherits WebFormsPageBase via _Imports.razor
var cookieValue = Request.Cookies["MyCookie"];// ❌ WRONG — Manual URL manipulation
[Inject] NavigationManager NavigationManager { get; set; }
NavigationManager.NavigateTo("/Products");✅ CORRECT — Use the ResponseShim:
// ✅ Works exactly like Web Forms
Response.Redirect("~/Products.aspx"); // Strips ~/ and .aspx automatically// ❌ WRONG — Only works in SSR, breaks in interactive mode
HttpContext.Response.Cookies.Append("CartId", cartId);✅ CORRECT — Use SessionShim instead:
// ✅ Works in both SSR and interactive modes
Session["CartId"] = cartId;// ❌ WRONG — Unnecessary ASP.NET Core endpoints
app.MapPost("/api/AddToCart", async (CartService cart, int productId) =>
{
await cart.AddItemAsync(productId);
return Results.Ok();
});✅ CORRECT — Keep as Blazor page/component methods:
// ✅ Original Web Forms pattern preserved
private async Task AddToCart_Click()
{
Session["CartId"] = await _cartService.AddItemAsync(productId);
}// ❌ WRONG — Forces SSR-only when shims handle interactive mode
@attribute [ExcludeFromInteractiveRouting]✅ CORRECT — Let pages run in interactive mode:
// ✅ Shims work in interactive mode — no attribute needed
@page "/Products"
@inherits WebFormsPageBaseONLY use [ExcludeFromInteractiveRouting] if:
<form method="post">// ❌ WRONG — Reinventing session management
Response.Cookies.Append("CartId", Guid.NewGuid().ToString(), new CookieOptions
{
Expires = DateTimeOffset.UtcNow.AddDays(30),
IsEssential = true
});✅ CORRECT — If Web Forms used Session, use SessionShim:
// ✅ Original pattern preserved
Session["CartId"] = Guid.NewGuid().ToString();// ❌ WRONG — JavaScript workarounds for navigation
<Button Text="View Details"
OnClientClick="window.location.href='/ProductDetails?id=5'; return false;" />✅ CORRECT — Use the BWFC Button with ResponseShim:
// ✅ Web Forms pattern works via shim
<Button Text="View Details" OnClick="@ViewDetails_Click" />
@code {
private void ViewDetails_Click()
{
Response.Redirect($"~/ProductDetails.aspx?id={productId}");
}
}// ❌ WRONG — Trying to force HTTP semantics into Blazor
app.MapFallback("/Products", async context =>
{
await context.Response.WriteAsync("Use the Blazor router!");
});✅ CORRECT — Work WITH Blazor using shims:
// ✅ Standard Blazor routing + shims = Web Forms compatibility
@page "/Products"
@inherits WebFormsPageBase
<GridView SelectMethod="GetProducts" />Use this flowchart when encountering Web Forms patterns:
Original code uses Response.Redirect()?
→ Use Response.Redirect() — ResponseShim handles it ✅
Original code uses Session["key"]?
→ Use Session["key"] — SessionShim handles it ✅
Original code uses Request.QueryString["key"]?
→ Use Request.QueryString["key"] — RequestShim handles it ✅
Original code uses Request.Cookies["key"]?
→ If page runs in interactive mode: Use Session instead (cookies need SSR)
→ If page can be SSR: Request.Cookies works via RequestShim
Original code uses HttpContext.Current.Session?
→ Replace HttpContext.Current.Session with Session property from WebFormsPageBase ✅
Need form POST data?
→ Wrap form in <WebFormsForm>, use Request.Form["key"] ✅
Original code uses Server.MapPath()?
→ Use Server.MapPath() — ServerShim handles it ✅
Original code uses Cache["key"]?
→ Use Cache["key"] — CacheShim handles it ✅
Original code uses ViewState["key"]?
→ Use ViewState["key"] — WebFormsPageBase provides it ✅
→ Consider refactoring to component fields for clarity
Original code uses ClientScript.RegisterStartupScript()?
→ Use ClientScript.RegisterStartupScript() — ClientScriptShim handles it ✅
Need to inject a service?
→ @inject MyService Service — standard Blazor DI ✅If the original Web Forms code uses Session["CartId"], the migrated code should use Session["CartId"]. The SessionShim makes this work. Don't reinvent the pattern — use the shims.
⚠️ CRITICAL: Always run L1 via the CLI tool. Do NOT apply L1 transforms manually. The tool produces deterministic, testable output. Manual L1 transforms corrupt measurement and miss edge cases.
webforms-to-blazor migrate -i ./MyWebFormsApp -o ./MyBlazorApp --report migration-report.json --verbose| Option | Description |
|---|---|
-i, --input <path> | Source Web Forms project root (required) |
-o, --output <path> | Output Blazor project directory (required) |
--report <path> | Write JSON migration report to file |
--report-format <fmt> | json (default) or markdown |
--skip-scaffold | Skip .csproj, Program.cs, _Imports.razor generation |
--dry-run | Show transforms without writing files |
-v, --verbose | Detailed per-file transform log |
--overwrite | Overwrite existing files in output directory |
webforms-to-blazor convert -i ./Pages/Products.aspx -o ./Pages/ --overwrite| Option | Description |
|---|---|
-i, --input <file> | .aspx, .ascx, or .master file (required) |
-o, --output <path> | Output directory (default: same directory) |
--overwrite | Overwrite existing .razor file |
Markup Transforms (16):
| # | Transform | Description |
|---|---|---|
| 1 | PageDirective | <%@ Page %> → @page "/route" with title extraction |
| 2 | MasterDirective | Remove <%@ Master %>, add @inherits LayoutComponentBase |
| 3 | ControlDirective | Remove <%@ Control %> directives |
| 4 | ImportDirective | <%@ Import Namespace="X" %> → @using X |
| 5 | RegisterDirective | Remove <%@ Register %> tag registrations |
| 6 | ContentWrapper | Strip <asp:Content> wrappers, convert HeadContent |
| 7 | FormWrapper | <form runat="server"> → <div> (preserves id for CSS) |
| 8 | GetRouteUrl | Page.GetRouteUrl() → GetRouteUrlHelper.GetRouteUrl() |
| 9 | Expression | <%: %> → @(), <%# Item.X %> → @context.X, Eval/Bind conversion |
| 10 | LoginView | Strip attributes, flag RoleGroups for review |
| 11 | SelectMethod | Preserve attribute, add TODO for delegate conversion |
| 12 | AjaxToolkitPrefix | ajaxToolkit:X → X (runs before asp: prefix) |
| 13 | AspPrefix | asp:X → X for all server controls |
| 14 | AttributeStrip | Remove runat="server", normalize ID → id |
| 15 | EventWiring | OnClick="Handler" → OnClick="@Handler" |
| 16 | UrlReference | ~/path → /path in href, NavigateUrl, ImageUrl |
Code-Behind Transforms (11):
| # | Transform | Description |
|---|---|---|
| 1 | UsingStrip | Remove System.Web.*, Microsoft.AspNet.* usings |
| 2 | BaseClassStrip | Remove : Page, : System.Web.UI.Page base classes |
| 3 | ResponseRedirect | ⚠️ DEPRECATED — L1 used to transform Response.Redirect() → NavigationManager.NavigateTo(), but this is WRONG. L2 should revert to Response.Redirect() and use ResponseShim. |
| 4 | SessionDetect | Detect Session["key"] patterns, inject // TODO(bwfc-session-state) guidance |
| 5 | ViewStateDetect | Detect ViewState["key"] patterns, inject // TODO(bwfc-viewstate) guidance |
| 6 | IsPostBack | Unwrap simple if (!IsPostBack) guards; TODO complex guards with else |
| 7 | PageLifecycle | Page_Load → OnInitializedAsync, Page_Init → OnInitialized, Page_PreRender → OnAfterRenderAsync |
| 8 | EventHandlerSignature | Strip (object sender, EventArgs e) from standard handlers |
| 9 | DataBind | Cross-file: ctrl.DataSource = x → field assignment, inject Items= in markup |
| 10 | UrlCleanup | "~/Products.aspx?id=5" → "/Products?id=5" in string literals |
| 11 | AttributeNormalize | Boolean, enum, and unit value normalization |
Scaffolding:
.csproj with BWFC NuGet referenceProgram.cs with AddBlazorWebFormsComponents() — registers ALL shims automatically (SessionShim, ResponseShim, RequestShim, ServerShim, CacheShim, ClientScriptShim, FormShim)_Imports.razor with BWFC usings and @inherits WebFormsPageBase — gives EVERY page access to Session, Response, Request, Server, Cache, ClientScript, ViewState, IsPostBack propertiesApp.razor with InteractiveServer render mode, detected CSS/JS referencesRoutes.razor, GlobalUsings.cs, launchSettings.jsonappsettings.json from web.config connection strings and app settingsWebFormsShims.cs, IdentityShims.cs when applicableApp_Start/BundleConfig.cs and RouteConfig.cs as no-op shims🔑 Key Point: The CLI scaffolding sets up the shim infrastructure automatically. You do NOT need to:
[Inject] attributes for Session, Response, Request, etc.The --report flag generates a JSON file that drives L2 decisions:
{
"summary": {
"filesProcessed": 24,
"transformsApplied": 187,
"todosGenerated": 12,
"scaffoldFilesCreated": 8
},
"todos": [
{
"category": "bwfc-session-state",
"file": "Cart.razor.cs",
"line": 15,
"message": "Session[\"CartId\"] detected — convert to scoped service",
"severity": "warning"
},
{
"category": "bwfc-identity-migration",
"file": "Login.razor.cs",
"line": 8,
"message": "FormsAuthentication.SignOut() → SignInManager.SignOutAsync()",
"severity": "warning"
}
],
"transforms": [ ... ],
"scaffolding": { ... }
}TODO categories map directly to L2 sections below:
bwfc-session-state → Session shim wiringbwfc-identity-migration → Auth conversion (delegate to /bwfc-identity-migration)bwfc-data-migration → DataSource → service conversion (delegate to /bwfc-data-migration)bwfc-viewstate → ViewState replacementbwfc-page-lifecycle → Complex lifecycle patterns L1 couldn't auto-convertbwfc-manual → Items requiring developer decisionAfter L1 completes, read the migration report (migration-report.json). For each TODO category, apply the corresponding transforms below.
⚠️ MANDATORY — READ BEFORE STARTING L2: Open and read all three child documents:
- CODE-TRANSFORMS.md — Lifecycle mapping, event handlers, data binding, Master Page → Shell
- CONTROL-REFERENCE.md — 58 BWFC component translation tables
- AJAX-TOOLKIT.md — Ajax Control Toolkit extender migration (14 components)
CRITICAL: L1's ResponseRedirect transform is WRONG. It converts Response.Redirect() to NavigationManager.NavigateTo(), which breaks the shim pattern.
L2 must revert this transform:
// L1 output (WRONG):
[Inject] NavigationManager NavigationManager { get; set; }
private void ViewProduct_Click()
{
NavigationManager.NavigateTo("/Products");
}
// L2 fix (CORRECT):
// Remove the [Inject] NavigationManager line
private void ViewProduct_Click()
{
Response.Redirect("~/Products.aspx"); // ✅ Shim handles this
}Search pattern: Look for [Inject] NavigationManager and NavigationManager.NavigateTo() calls that originated from Web Forms Response.Redirect().
Fix:
[Inject] NavigationManager NavigationManager { get; set; }NavigationManager.NavigateTo("/path") with Response.Redirect("~/path.aspx")~/ and .aspx automaticallyL1 detects Session["key"] patterns and inserts guidance comments. L2 preserves the original pattern — no code changes needed.
✅ The Original Pattern Works AS-IS:
// Original Web Forms code:
Session["CartId"] = cartId;
var id = Session["CartId"]?.ToString();
// Migrated Blazor code (IDENTICAL):
Session["CartId"] = cartId;
var id = Session["CartId"]?.ToString();Why this works:
_Imports.razor contains @inherits WebFormsPageBaseWebFormsPageBase provides a Session property backed by SessionShimAddBlazorWebFormsComponents() in Program.cs registers SessionShim automaticallyDO NOT:
IHttpContextAccessor to access HttpContext.SessionSessionShim existsSession["key"] to await SessionStorage.GetAsync("key") (different pattern)DO:
Session["key"] code unchangedSessionShim handle the storage (in-memory per circuit)Session.Get<T>("key") for strongly-typed access if desiredNote:
SessionShimis an in-memory per-circuit store. It does NOT persist across browser refreshes. For durable state, migrate to a scoped DI service with server-side persistence.
For non-page components that need session access, inject SessionShim directly:
@inject SessionShim Session
@code {
protected override void OnInitialized()
{
var cartId = Session["CartId"]?.ToString(); // ✅ Same pattern
}
}L1 detects FormsAuthentication.*, Membership.*, and Roles.* calls. These require deep auth migration.
Quick patterns:
// Before (L1 output with TODO):
// TODO(bwfc-identity-migration): FormsAuthentication.SignOut() → SignInManager.SignOutAsync()
FormsAuthentication.SignOut();
// After (L2):
await SignInManager.SignOutAsync();For full auth migration, invoke the /bwfc-identity-migration skill — it handles ASP.NET Membership → ASP.NET Core Identity conversion, including database schema migration, cookie configuration, and role-based authorization.
L1 removes DataSourceID attributes from data-bound controls and replaces <asp:SqlDataSource>, <asp:ObjectDataSource>, and <asp:EntityDataSource> controls with TODO comments.
Pattern — SqlDataSource → injected service:
// Before (Web Forms):
// <asp:SqlDataSource ID="ProductsDS" SelectCommand="SELECT * FROM Products" />
// <asp:GridView DataSourceID="ProductsDS" />
// After L1:
// TODO(bwfc-data-migration): Replace SqlDataSource "ProductsDS" with injected service
// <GridView />
// After L2:
@inject ProductService ProductService
<GridView ItemType="Product" SelectMethod="@ProductService.GetProducts" />For full data migration, invoke the /bwfc-data-migration skill — it handles EF6 → EF Core conversion, service extraction, and repository patterns.
L1 detects ViewState["key"] access patterns but cannot determine replacement strategy without context.
Pattern — simple value storage → component field:
// Before (L1 output with TODO):
// TODO(bwfc-viewstate): ViewState["SortColumn"] detected — replace with component field or parameter
ViewState["SortColumn"] = "Name";
var sort = ViewState["SortColumn"]?.ToString();
// After (L2):
private string _sortColumn = "Name";Pattern — cross-page state → cascading parameter or query string:
// Before:
// TODO(bwfc-viewstate): ViewState["SelectedId"] detected
ViewState["SelectedId"] = selectedId;
// After (if needed across navigations):
NavigationManager.NavigateTo($"/Details?id={selectedId}");
// Or (if parent-child component communication):
[CascadingParameter] public int SelectedId { get; set; }Pattern — ViewStateDictionary shim (compile-compatibility bridge):
// For complex ViewState usage that can't be trivially replaced,
// BWFC's ViewStateDictionary provides a per-component dictionary:
// Code-behind that uses ViewState["key"] compiles unchanged via WebFormsPageBase.L1 auto-converts simple lifecycle methods but flags complex patterns it cannot handle:
Complex IsPostBack guards with else:
// L1 output (flagged, not unwrapped):
// TODO(bwfc-page-lifecycle): IsPostBack guard with else clause — review manually
if (!IsPostBack)
{
LoadInitialData();
}
else
{
ProcessPostBackData();
}
// L2 fix: Move 'if' body to OnInitializedAsync, 'else' body to event handlers
protected override async Task OnInitializedAsync()
{
await LoadInitialDataAsync();
}
// ProcessPostBackData() logic moves to the specific event handler that triggers itPage_Load with async operations:
// L1 converts signature but can't determine async boundaries:
protected override async Task OnInitializedAsync()
{
products = GetProducts(); // TODO(bwfc-page-lifecycle): consider making async
}
// L2 fix:
protected override async Task OnInitializedAsync()
{
products = await GetProductsAsync();
}Page_PreRender patterns:
// L1 converts to OnAfterRenderAsync but complex logic needs review:
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
lblCount.Text = products.Count.ToString();
StateHasChanged(); // Required — OnAfterRenderAsync runs AFTER render
}
}Items requiring developer decision. Document these and move on:
HttpModule / HttpHandler implementationsPage_Error / Application_Error patternsControls.Add(new TextBox()))Literal.Mode = LiteralMode.PassThrough with raw HTML injectionWebPart or WebPartZone usageAction: Create a MIGRATION-NOTES.md file documenting each manual item with context and recommended approach.
⚠️ MANDATORY: SelectMethod MUST be preserved as a delegate. Do NOT convert to
Items=binding — this is the #1 recurring migration error.
// Before (Web Forms): SelectMethod="GetProducts"
// After (L2): SelectMethod="@productService.GetProducts"
// BWFC's DataBoundComponent.OnAfterRenderAsync calls the delegate to populate Items.Full L2 checklist for each file:
SelectMethod string → SelectHandler<ItemType> delegate referenceItemType attribute (strip namespace prefix only)Context="Item" to <ItemTemplate> elementsItems: Items="@(_products ?? new())"SelectMethod is set, Items is auto-populated — do NOT also set Items@inject directives for required services (NavigationManager, DbContext, etc.)cd MyBlazorApp
dotnet buildCommon build errors and fixes:
| Error | Cause | Fix |
|---|---|---|
CS0246: 'Page' could not be found | Missing @inherits WebFormsPageBase | Verify _Imports.razor has @inherits BlazorWebFormsComponents.WebFormsPageBase |
CS0103: 'Session' does not exist | Non-page component using Session | Add @inject SessionShim Session |
CS0103: 'Response' does not exist | Code-behind using Response.Redirect | L1 should have converted; check for missed patterns |
CS1061: 'X' does not contain 'DataBind' | Explicit .DataBind() calls remaining | Remove — BWFC auto-binds via SelectMethod or Items |
CS0234: 'Web' does not exist in 'System' | Remaining System.Web.* using | Remove unless it's a BWFC shim namespace (System.Web.Optimization, System.Web.Routing) |
RZ9986: Component attributes do not support complex content | Expression in attribute without @() | Wrap with @(): Value="@(expr)" |
These require human judgment and cannot be automated:
WebControl / CompositeControl subclasses need manual Blazor component creation/bwfc-identity-migration/bwfc-data-migrationStateHasChanged() call optimization, virtualization for large lists_Imports.razor:
@using BlazorWebFormsComponents
@using BlazorWebFormsComponents.Enums
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@inherits BlazorWebFormsComponents.WebFormsPageBaseThe @inherits line gives every page Page.Title, Page.MetaDescription, IsPostBack, Session, Server, Response, Request, Cache, ViewState, ClientScript, PostBack event, ResolveUrl(), and GetRouteUrl() — so Web Forms code-behind compiles unchanged.
Note:
@rendermode InteractiveServeris a directive attribute for component instances, NOT a standalone line in_Imports.razor.
Program.cs:
builder.Services.AddBlazorWebFormsComponents();
var app = builder.Build();
app.UseConfigurationManagerShim();App.razor — render mode and BWFC script:
<HeadOutlet @rendermode="InteractiveServer" />
<Routes @rendermode="InteractiveServer" />
<script src="_content/Fritz.BlazorWebFormsComponents/js/Basepage.js"></script>Layout (MainLayout.razor):
@inherits LayoutComponentBase
<BlazorWebFormsComponents.Page />
<header><!-- ... --></header>
<main>@Body</main>Important:
WebFormsPageBaseprovides the code-behind API. The<BlazorWebFormsComponents.Page />component renders<PageTitle>and<meta>tags. Both are required.
| Shim | Web Forms API | Blazor Implementation | Setup |
|---|---|---|---|
| ConfigurationManager | ConfigurationManager.AppSettings["key"], .ConnectionStrings["name"] | Reads from IConfiguration | app.UseConfigurationManagerShim() |
| SessionShim | Session["key"] indexer, .Get<T>(), .Remove(), .Clear(), .ContainsKey() | In-memory per-circuit + optional ISession sync | Auto-registered by AddBlazorWebFormsComponents() |
| ServerShim | Server.MapPath(), Server.HtmlEncode(), Server.HtmlDecode(), Server.UrlEncode(), Server.UrlDecode() | Wraps IWebHostEnvironment + WebUtility | Auto-registered by AddBlazorWebFormsComponents() |
| CacheShim | Cache["key"] indexer, Cache.Insert(), Cache.Get<T>(), Cache.Remove() | Wraps IMemoryCache with absolute/sliding expiration | Auto-registered by AddBlazorWebFormsComponents() |
| ResponseShim | Response.Redirect(), Response.Cookies | Wraps NavigationManager + HttpContext; auto-strips ~/ and .aspx | Via WebFormsPageBase.Response |
| RequestShim | Request.QueryString, Request.Cookies, Request.Url, Request.Form | Wraps NavigationManager + HttpContext; Form via FormShim | Via WebFormsPageBase.Request |
| FormShim | Request.Form["key"], .GetValues(), .AllKeys, .Count, .ContainsKey() | Wraps IFormCollection (SSR) or JS interop data (interactive) | Via RequestShim.Form — populated by <WebFormsForm> |
| ClientScriptShim | Page.ClientScript.RegisterStartupScript(), .RegisterClientScriptBlock(), .RegisterClientScriptInclude(), .GetPostBackEventReference() | Queues scripts, flushes via IJSRuntime in OnAfterRenderAsync | Auto-registered by AddBlazorWebFormsComponents() |
| ScriptManagerShim | ScriptManager.GetCurrent(page), .RegisterStartupScript(), .RegisterClientScriptBlock(), .RegisterClientScriptInclude() | Delegates to ClientScriptShim | Auto-registered by AddBlazorWebFormsComponents() |
| ViewStateDictionary | ViewState["key"] indexer | Per-component in-memory dictionary | Via WebFormsPageBase.ViewState |
| BundleConfig/RouteConfig | BundleTable.Bundles.Add(), RouteTable.Routes.MapPageRoute() | No-op stubs | Compile-only — no setup needed |
The <WebFormsForm> component enables Request.Form["key"] access in interactive Blazor Server mode where HttpContext and IFormCollection are unavailable. It captures form data via JS interop and feeds it to RequestShim.Form.
Before (Web Forms):
<form runat="server">
<asp:TextBox ID="txtName" runat="server" />
<asp:Button Text="Submit" OnClick="Submit_Click" runat="server" />
</form>
// Code-behind:
protected void Submit_Click(object sender, EventArgs e)
{
var name = Request.Form["txtName"];
}After (Blazor with BWFC):
<WebFormsForm OnSubmit="SetRequestFormData">
<TextBox @bind-Text="name" />
<Button Text="Submit" OnClick="Submit_Click" />
</WebFormsForm>
@code {
private string name;
private void Submit_Click()
{
// Request.Form["txtName"] works via FormShim
var formName = Request.Form["txtName"];
}
}Key points:
<WebFormsForm> renders a standard <form> elementOnSubmit captures form data via JS interop and populates Request.FormOnSubmit="SetRequestFormData" to auto-wire form data into WebFormsPageBase.Request.FormMethod (Get/Post) and Action parametersIFormCollection — no JS interop neededWhen to use <WebFormsForm> vs native Blazor forms:
<WebFormsForm> when migrated code-behind accesses Request.Form["key"] directly<EditForm> for new Blazor forms with model binding<form method="post" action="/endpoint"> for auth operations (see identity migration skill)ClientScriptShim provides a compile-compatible bridge for Page.ClientScript patterns. It queues scripts during the component lifecycle and flushes them via IJSRuntime after render.
Before (Web Forms):
Page.ClientScript.RegisterStartupScript(GetType(), "init",
"alert('Page loaded!');", addScriptTags: true);
Page.ClientScript.RegisterClientScriptInclude("jquery",
"~/Scripts/jquery.min.js");
if (!Page.ClientScript.IsStartupScriptRegistered(GetType(), "init"))
{
Page.ClientScript.RegisterStartupScript(GetType(), "init", "doInit();", true);
}After (Blazor with BWFC — via WebFormsPageBase.ClientScript):
// Code-behind compiles unchanged — ClientScript is a property on WebFormsPageBase
ClientScript.RegisterStartupScript(GetType(), "init",
"alert('Page loaded!');", addScriptTags: true);
ClientScript.RegisterClientScriptInclude("jquery",
"/Scripts/jquery.min.js");
if (!ClientScript.IsStartupScriptRegistered(GetType(), "init"))
{
ClientScript.RegisterStartupScript(GetType(), "init", "doInit();", true);
}ScriptManager code-behind also works:
// Before (Web Forms):
var sm = ScriptManager.GetCurrent(this.Page);
sm.RegisterStartupScript(this, GetType(), "key", "doWork();", true);
// After (Blazor — via ScriptManagerShim):
var sm = ScriptManagerShim.GetCurrent(this);
sm.RegisterStartupScript(this, GetType(), "key", "doWork();", true);When to use shim vs. native IJSRuntime:
Page.ClientScript code compiles unchangedIJSRuntime — no performance differenceWebFormsPageBase provides PostBack compatibility via JS interop. The __doPostBack() JavaScript function is auto-bootstrapped and routes events back to the Blazor component.
Before (Web Forms):
// IPostBackEventHandler implementation
public void RaisePostBackEvent(string eventArgument)
{
// Handle postback with argument
ProcessAction(eventArgument);
}
// Client-side trigger
Page.ClientScript.GetPostBackEventReference(this, "delete:42");After (Blazor with BWFC):
@inherits WebFormsPageBase
@code {
protected override void OnInitialized()
{
PostBack += OnPostBack;
}
private void OnPostBack(object sender, PostBackEventArgs e)
{
// e.EventTarget = control ID, e.EventArgument = "delete:42"
ProcessAction(e.EventArgument);
}
}PostBack API surface on WebFormsPageBase:
event EventHandler<PostBackEventArgs> PostBack — raised when __doPostBack() firesClientScript.GetPostBackEventReference(control, argument) — returns JS expression stringClientScript.GetPostBackClientHyperlink(control, argument) — returns javascript:__doPostBack(...) URLClientScript.GetCallbackEventReference(...) — returns __bwfc_callback(...) expressionHandlePostBackFromJs(eventTarget, eventArgument) — [JSInvokable] bridge methodHandleCallbackFromJs(eventTarget, eventArgument) — [JSInvokable] callback bridge (override in derived pages)appsettings.json mapping (from web.config):
{
"AppSettings": {
"SiteName": "My Store",
"ItemsPerPage": "20"
},
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=MyDb;Trusted_Connection=True;"
}
}See CONTROL-REFERENCE.md for the full translation table of 58 BWFC components across 6 categories.
| Web Forms Expression | Blazor Equivalent | Notes |
|---|---|---|
<%: expression %> | @(expression) | HTML-encoded output |
<%= expression %> | @(expression) | Blazor always 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 |
<%$ RouteValue:id %> | @Id (with [Parameter]) | Route parameters |
<%-- comment --%> | @* comment *@ | Razor comments |
<% if (cond) { %> | @if (cond) { | Control flow |
<% foreach (var x in items) { %> | @foreach (var x in items) { | Loops |
| Web Forms | Blazor |
|---|---|
MyPage.aspx + .aspx.cs | MyPage.razor + .razor.cs |
MyControl.ascx + .ascx.cs | MyControl.razor + .razor.cs |
Site.Master + .Master.cs | MainLayout.razor + .razor.cs |
| Web Forms Directive | Blazor Equivalent |
|---|---|
<%@ Page Title="X" ... %> | @page "/route" |
<%@ Master ... %> | (remove — layouts don't need directives) |
<%@ Control ... %> | (remove — components don't need directives) |
<%@ Register TagPrefix="uc" Src="~/X.ascx" %> | @using MyApp.Components |
<%@ Import Namespace="X" %> | @using X |
Drop entirely: AutoEventWireup, CodeBehind, Inherits, EnableViewState, MasterPageFile, ValidateRequest, ClientIDMode, EnableTheming, SkinID
| Web Forms | Blazor |
|---|---|
<asp:Content ContentPlaceHolderID="MainContent"> | <Content ContentPlaceHolderID="MainContent"> inside <ChildComponents> |
<asp:Content ContentPlaceHolderID="HeadContent"> | Prefer page-level <HeadContent> or shell <Head> depending on ownership |
<asp:ContentPlaceHolder ID="MainContent" /> | <ContentPlaceHolder ID="MainContent" /> inside <ChildContent> |
| Web Forms | Blazor |
|---|---|
href="~/Products" | href="/Products" |
NavigateUrl="~/Products/<%: Item.ID %>" | NavigateUrl="@($"/Products/{context.ID}")" |
GetRouteUrl("Route", new { id = Item.ID }) | @($"/Products/{context.ID}") or GetRouteUrlHelper |
Response.Redirect("~/Products") | NavigationManager.NavigateTo("/Products") |
@* Before: <%@ Master Language="C#" CodeBehind="Site.master.cs" %> *@
@* After: *@
<MasterPage>
<Head>
<title>@(Page.Title)</title>
</Head>
<ChildContent>
<header>
<nav><Menu ... /></nav>
</header>
<main>
<ContentPlaceHolder ID="MainContent" />
</main>
<footer>© @DateTime.Now.Year</footer>
@ChildContent
</ChildContent>
</MasterPage>
@code {
[Parameter]
public RenderFragment? ChildContent { get; set; }
}Key changes:
<form runat="server"> → removed from the shell wrapper<asp:ContentPlaceHolder ID="MainContent"> → <ContentPlaceHolder ID="MainContent"><asp:ScriptManager> → <ScriptManager /> (renders nothing)<head> → shell <Head> content<ChildComponents>Tip: Collapse to native
@layout+@Bodyonly after the migrated shell truly behaves like a single-slot layout. Until then, keep the BWFC shell contract intact.
Replace ViewState["key"] with component fields. ViewStateDictionary shim available for compile-compat.
WebFormsPageBase.IsPostBack works correctly: returns false for SSR GET / interactive first render, true for SSR POST / interactive subsequent renders. L1 auto-unwraps simple if (!IsPostBack) guards. Complex guards (with else) get TODO comments. For __doPostBack() JavaScript patterns, subscribe to the PostBack event on WebFormsPageBase — see PostBack Event Handling above.
SqlDataSource, ObjectDataSource, EntityDataSource → injected services. See /bwfc-data-migration.
Blazor doesn't render component IDs. Use CssClass or explicit id attributes for CSS/JS targeting.
Add Context="Item" on template elements:
<ItemTemplate Context="Item">
@Item.PropertyName
</ItemTemplate>// Web Forms: protected void Btn_Click(object sender, EventArgs e) { }
// Blazor: private void Btn_Click() { }L1 auto-strips standard EventArgs. Specialized types (CommandEventArgs, etc.) are preserved.
TextMode="MultiLine" CasingBWFC uses Multiline (lowercase 'l'), not MultiLine. Silent failure if wrong.
ScriptManager and ScriptManagerProxy Razor components are no-op stubs (render nothing). For code-behind patterns like ScriptManager.GetCurrent(page).RegisterStartupScript(...), use ScriptManagerShim.GetCurrent(this) which delegates to ClientScriptShim. Include the Razor components during migration to prevent markup errors; remove when stable.
runat="server" on HTML ElementsL1 removes these. Use @ref if programmatic access is needed.
| Problem | Solution |
|---|---|
webforms-to-blazor not found | Run dotnet tool install -g Fritz.WebFormsToBlazor |
| Tool version mismatch | Run dotnet tool update -g Fritz.WebFormsToBlazor |
| Output directory not empty | Use --overwrite flag |
| Need to preview changes first | Use --dry-run flag |
| Missing scaffolding files | Don't use --skip-scaffold unless you have an existing Blazor project |
| Problem | Solution |
|---|---|
SelectMethod not firing | Ensure it's a delegate reference (@service.Method), not a string |
Items always empty | Check that SelectMethod signature matches SelectHandler<T> delegate |
| Template binding errors | Add Context="Item" to <ItemTemplate> elements |
| Session data lost on refresh | SessionShim is per-circuit; use persistent storage for critical data |
| Infinite render loop | Guard OnAfterRenderAsync with if (firstRender), call StateHasChanged() only when needed |
## Page: [PageName.aspx] → [PageName.razor]
### L1 — CLI Tool (automated)
- [ ] `webforms-to-blazor migrate` or `convert` executed
- [ ] Migration report reviewed
- [ ] File renamed (.aspx → .razor)
- [ ] Directives converted
- [ ] asp: prefixes removed
- [ ] runat="server" removed
- [ ] Expressions converted
- [ ] URLs converted
- [ ] Content wrappers removed
- [ ] IsPostBack guards unwrapped/TODO'd
- [ ] .aspx URL literals cleaned up
### L2 — Copilot Transforms (per TODO category)
- [ ] TODO(bwfc-session-state) items resolved
- [ ] TODO(bwfc-viewstate) items resolved
- [ ] TODO(bwfc-page-lifecycle) items resolved
- [ ] TODO(bwfc-data-migration) items resolved or delegated
- [ ] TODO(bwfc-identity-migration) items resolved or delegated
- [ ] TODO(bwfc-manual) items documented
- [ ] SelectMethod string → SelectHandler delegate
- [ ] Template Context="Item" verified
- [ ] @inject directives added
### Verification
- [ ] `dotnet build` succeeds
- [ ] Page renders correctly
- [ ] Interactive features work
- [ ] No browser console errors| Error Signature | Recipe File |
|---|---|
CS7036: no argument ... 'options' of 'XxxContext' | recipes/new-dbcontext-to-di.md |
CS0103 on @ref fields, no .razor.cs | recipes/missing-code-behind.md |
CS1061: 'GridView<T>' ... 'Rows'/'FindControl' | recipes/gridview-row-findcontrol.md |
CS1061: ... 'InnerText' | recipes/innertext-to-markup.md |
CS1503: SelectMethod ... 'string' to 'SelectHandler' | recipes/selectmethod-string-binding.md |
| CSS/layout visual regression | recipes/layout-css-body-class.md |
CS1061: 'RequestShim' ... 'IsLocal' | recipes/request-shim-gaps.md |
CS0103 on OAuth fields | recipes/oauth-page-stubs.md |
CS0246: 'IDatabaseInitializer' | recipes/database-seed-initializer.md |
Session.SetString(key, = null) garbled syntax | recipes/session-transform-garbling.md |
| Circular DI: class injects itself | recipes/circular-self-injection.md |
CS1503/CS0123: EventCallback signature | recipes/eventcallback-signature-mismatch.md |
CS0542: nested class same name as outer | recipes/nested-class-collision.md |
147d0c4
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.