Skip to content

Quickstart: Scan → Migrate → Verify

Go from "I have a Web Forms app" to "I have a running Blazor app" in the shortest path.

This guide walks you through the linear steps. It doesn't explain why each step exists — see Methodology for the theory behind the pipeline.


Before You Start

  • [ ] .NET 10+ SDK installed (dotnet --version)
  • [ ] PowerShell 7+ installed (pwsh --version)
  • [ ] Your Web Forms project compiles and runs on .NET Framework
  • [ ] Git initialized in your project (you'll want to track changes)

Step 1: Install BWFC

Create your Blazor project and add the BWFC package:

dotnet new blazor -n MyBlazorApp --interactivity Server
cd MyBlazorApp
dotnet add package Fritz.BlazorWebFormsComponents

Step 2: Scan Your Web Forms Project

Run the scanner against your existing Web Forms project to understand what you're working with:

# From the BWFC repo root
.\scripts\bwfc-scan.ps1 -Path "C:\src\MyWebFormsApp" -OutputFormat Markdown -OutputFile scan-report.md

The scanner inventories every .aspx, .ascx, and .master file — extracting control usage, data binding patterns, and DataSource controls. Review the report to understand:

  • Total page count and complexity distribution
  • Control coverage — what percentage of your controls BWFC supports
  • DataSource controls — these need manual replacement (no BWFC equivalent)
  • Migration readiness score — your starting point

📄 Script reference: scripts/bwfc-scan.ps1


Step 3: Run Layer 1 — Automated Transforms

Layer 1 applies mechanical transforms deterministically. Use either the CLI tool (recommended) or the PowerShell script:

Option A: CLI tool (37 compiled transforms, migration report):

dotnet run --project src/BlazorWebFormsComponents.Cli -- migrate -i "C:\src\MyWebFormsApp" -o "C:\src\MyBlazorApp"

Option B: PowerShell script (lightweight, no build required):

.\scripts\bwfc-migrate.ps1 -Path "C:\src\MyWebFormsApp" -Output "C:\src\MyBlazorApp"

What this does (in ~30 seconds for a typical app):

Transform Example
Strip asp: prefixes <asp:Button><Button>
Remove runat="server" runat="server"(removed)
Convert expressions <%: Item.Name %>@(Item.Name)
Convert URLs ~/Products/Products
Rename files Default.aspxDefault.razor
Convert ItemType ItemType="NS.Product"TItem="Product"
Remove content wrappers <asp:Content>(unwrapped)
Scaffold project Generates .csproj, Program.cs, _Imports.razor

Dry-run first to preview changes without writing files:

# CLI: use the --dry-run flag
dotnet run --project src/BlazorWebFormsComponents.Cli -- migrate -i "C:\src\MyWebFormsApp" -o "C:\src\MyBlazorApp" --dry-run

# PowerShell alternative:
.\scripts\bwfc-migrate.ps1 -Path "C:\src\MyWebFormsApp" -Output "C:\src\MyBlazorApp" -WhatIf

📄 Script reference: scripts/bwfc-migrate.ps1


Step 4: Configure BWFC in the Blazor Project

After the migration script runs, verify these are in place (the script scaffolds them, but check):

_Imports.razor — add BWFC namespaces and page base class:

@using BlazorWebFormsComponents
@using BlazorWebFormsComponents.Enums
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@inherits BlazorWebFormsComponents.WebFormsPageBase

This one line gives every page the Web Forms API: - Page.Title, Page.MetaDescription, Page.MetaKeywords - IsPostBack (false on first render, true on interactions) - Session["key"] (scoped in-memory dictionary) - Response.Redirect("~/path") (auto-strips ~/ and .aspx) - Request.Url, Request.QueryString["key"], Request.QueryString.Get("key"), Request.Form["key"] - Cache["key"] (application-level cache) - Server.MapPath("~/path"), Server.Transfer("~/path"), Server.GetLastError(), Server.ClearError() - System.Web.HttpUtility.UrlEncode() and HtmlEncode() are rewritten by the CLI to WebUtility.* - ClientScript.RegisterStartupScript(...) (JS interop)

Your Web Forms code-behind compiles with minimal manual conversion. Most preserved APIs stay unchanged, and HttpUtility calls are normalized automatically during Layer 1.

Program.cs — register BWFC services:

builder.Services.AddBlazorWebFormsComponents();

What this does: - Registers SessionShim (scoped in-memory dictionary for Session["key"]) - Registers ResponseShim (handles Response.Redirect, Response.Write) - Registers RequestShim (provides Request.QueryString, Request.Form, Request.Url) - Registers CacheShim (in-memory application cache) - Registers ServerShim (provides Server.MapPath, Server.Transfer, and error-method compatibility) - Registers ClientScriptShim (JS interop for ClientScript.RegisterStartupScript) - Registers ViewStateShim (compile-compatible dictionary)

After this single call, most Web Forms APIs work AS-IS in your migrated code. HttpUtility is handled by the CLI through inline WebUtility rewrites during Layer 1.

Layout (MainLayout.razor) — add the Page render component:

<BlazorWebFormsComponents.Page />

This renders <PageTitle> and <meta> tags. WebFormsPageBase provides the code-behind API, <Page /> does the rendering — both are required.

App.razor (or layout head) — add BWFC JavaScript:

<script src="_content/Fritz.BlazorWebFormsComponents/js/Basepage.js"></script>


Step 5: Set Up Copilot for Layer 2

Copy the Copilot instructions template into your project to give Copilot migration-specific context:

# From your Blazor project root
mkdir -p .github
cp path/to/bwfc-repo/migration-toolkit/copilot-instructions-template.md .github/copilot-instructions.md

Then open .github/copilot-instructions.md and fill in the <!-- FILL IN --> sections with your project-specific details.

Alternatively, point Copilot at the BWFC migration skill directly:

📄 Skill file: Core Migration Skill


Step 6: Walk Through Layer 2 — Copilot-Assisted Transforms

Open each migrated .razor file and work through the structural transforms that the script couldn't handle. These are the patterns Copilot handles well with the migration skill:

💡 Many Web Forms API calls now compile unchanged thanks to BWFC shims. Response.Redirect, Session["key"], IsPostBack, Page.Title, Request.QueryString, Cache, and Server.* all work AS-IS. HttpUtility is rewritten automatically to WebUtility during Layer 1. Focus Layer 2 effort on data binding, templates, and event handler signatures.

Transform What To Do
SelectMethodItems Replace SelectMethod="GetProducts" with Items="products", load data in OnInitializedAsync
ItemTypeTItem Already done by Layer 1, but verify generic type parameter is correct
Template context Add Context="Item" to <ItemTemplate>, <EditItemTemplate>, etc.
Code-behind lifecycle Convert Page_Load(object sender, EventArgs e) signature → OnInitializedAsync; IsPostBack inside works AS-IS
Event handlers Convert void Btn_Click(object sender, EventArgs e)void Btn_Click()
Form wrappers Remove <form runat="server">; use <WebFormsForm> if page uses Request.Form, or <EditForm> for validation
Master Page shell Convert to runnable <MasterPage> + <ChildContent> first; normalize page sections under <ChildComponents>

If your pages use Request.Form, wrap the form content in <WebFormsForm> — this component captures form POST data and feeds the FormShim so Request.Form["key"] works in your code-behind.

The following are no longer Layer 2 work — they work AS-IS via shims: - ~~Response.Redirect("~/path")NavigationManager.NavigateTo~~ → works via ResponseShim - ~~Session["key"] → mark for Layer 3~~ → works via SessionShim - ~~Page.Title conversion~~ → works via WebFormsPageBase

Using Shims (No Conversion Needed)

The shims preserve Web Forms API calls AS-IS. Here's what works unchanged:

// Session access — works exactly like Web Forms
Session["CartId"] = cartId;
var cartId = Session["CartId"];

// Response.Redirect — auto-strips ~/ and .aspx
Response.Redirect("~/Products");
Response.Redirect("~/Product.aspx?id=5"); // becomes /Product/5 if routing configured

// Request.QueryString — reads URL parameters
var productId = Request.QueryString["id"];
var productName = Request.QueryString.Get("name");

// Request.Form — reads form POST data (requires <WebFormsForm> wrapper)
var username = Request.Form["username"];

// IsPostBack — false on first render, true on interactions
if (!IsPostBack)
{
    LoadInitialData();
}

// Page properties — auto-rendered by <Page /> component
Page.Title = "Product Details";
Page.MetaDescription = "View product details";

// Cache — application-level cache
Cache["RecentProducts"] = products;

// Server.MapPath — virtual to physical path
var filePath = Server.MapPath("~/App_Data/config.xml");

Do NOT inject these services manually: - ❌ IHttpContextAccessor — use Request property instead - ❌ NavigationManager (for redirects) — use Response.Redirect() instead - ❌ IMemoryCache — use Cache property instead - ❌ IJSRuntime (for startup scripts) — use ClientScript.RegisterStartupScript() instead

The shim properties are already available via WebFormsPageBase. Injecting these services and manually converting is extra work that provides no migration benefit.

Look for <!-- TODO: BWFC-MIGRATE --> comments left by the migration script — these mark items that need manual attention.


Step 7: Address Layer 3 — Architecture Decisions

These are the decisions that need a human (or a human + the migration agent):

  • Data access: Replace SqlDataSource/ObjectDataSource with injected services
  • Session state: Convert Session["key"] to scoped services or ProtectedSessionStorage (if you need persistence or distributed sessions — basic usage works AS-IS via SessionShim)
  • Authentication: Migrate ASP.NET Membership/Identity to ASP.NET Core Identity
  • EF6 → EF Core: Update DbContext, register with DI, adjust LINQ queries
  • Global.asax → Program.cs: Convert lifecycle hooks to middleware
  • Third-party integrations: Port to HttpClient pattern
  • Shim replacement (OPTIONAL): Replace Response.Redirect() with NavigationManager.NavigateTo(), Session with injected state services, etc. — this is a performance/modernization step, NOT a migration requirement

📄 For interactive guidance, use the Data & Architecture Migration Skill

Common Mistakes to Avoid

Anti-pattern #1: Manually converting shim-supported APIs

Wrong:

@inject NavigationManager Nav
@code {
    void GoToProducts() => Nav.NavigateTo("/Products");
}

Correct (use the shim):

@code {
    void GoToProducts() => Response.Redirect("~/Products");
}

Anti-pattern #2: Injecting services that shims already provide

Wrong:

@inject IHttpContextAccessor HttpContext
@code {
    var id = HttpContext.HttpContext.Request.Query["id"];
}

Correct (use the shim):

@code {
    var id = Request.QueryString["id"];
}

Anti-pattern #3: Treating shims as temporary scaffolding

Wrong mindset: "I'll use shims to get it compiling, then replace them with 'real' Blazor code."

Correct mindset: "Shims are the migration strategy. They work correctly. Replacing them is an optional optimization I can do later if my team wants to reduce BWFC dependency."

The shims ARE the solution, not a workaround.


Step 8: Build and Verify

dotnet build

Fix any compilation errors. Common issues at this stage:

  • Missing @using statements for model namespaces
  • Event handler signature mismatches (Web Forms EventArgs vs. Blazor parameterless)
  • Unresolved SelectMethod references that should be Items bindings

Once it builds:

dotnet run

Open the app in a browser and compare against your original Web Forms application:

  • [ ] Pages render without errors
  • [ ] Visual layout matches the original
  • [ ] Interactive features work (buttons, forms, navigation)
  • [ ] No console errors in browser dev tools

Step 9: Iterate

Use the per-page checklist to track progress across your application. Migrate pages in priority order:

  1. Leaf pages first — simple display pages with no dependencies
  2. Shared layouts — Master Page → MainLayout.razor
  3. Data-bound pages — pages with GridView, ListView, FormView
  4. Auth-dependent pages — login, account management
  5. Integration pages — checkout, payment, external APIs

What Comes Next

If you need... Go to...
Understand the pipeline theory Methodology
Check if a specific control is supported Control Coverage
Track per-page migration progress Migration Checklist
Set up Copilot instructions for your team Copilot Skills Overview