Custom WebControl → Blazor Migration
Overview
ASP.NET Web Forms custom server controls inherit from System.Web.UI.WebControls.WebControl and render HTML imperatively via HtmlTextWriter. BlazorWebFormsComponents provides a drop-in replacement that lets this code work unchanged in Blazor.
The key principle: change one using statement, keep your code.
csharp// Before (Web Forms) using System.Web.UI.WebControls; // After (Blazor + BWFC) using BlazorWebFormsComponents.CustomControls;
How It Works
The BWFC WebControl class overrides Blazor's BuildRenderTree:
- Creates an
HtmlTextWriterinstance (backed byStringBuilder) - Calls
AddAttributesToRender(writer)— emits ID, class, style, tooltip, disabled - Calls
Render(writer)→RenderBeginTag→RenderContents→RenderEndTag - Captures the HTML string via
writer.GetHtml() - Emits it via
builder.AddMarkupContent(0, html)
Your imperative rendering code produces the exact same HTML it did in Web Forms.
Supported Patterns
Pattern 1: TagKey + RenderContents (Most Common)
The classic Web Forms pattern — override TagKey for the outer element, RenderContents for the inner HTML:
csharpusing BlazorWebFormsComponents.CustomControls; using Microsoft.AspNetCore.Components; public class StatusBadge : WebControl { [Parameter] // Only addition for Blazor public string Status { get; set; } protected override HtmlTextWriterTag TagKey => HtmlTextWriterTag.Span; protected override void RenderContents(HtmlTextWriter writer) { writer.RenderBeginTag(HtmlTextWriterTag.Strong); writer.Write(Status); writer.RenderEndTag(); } }
Pattern 2: Full Render Override
For controls that need complete control over output:
csharppublic class CustomButton : WebControl { [Parameter] public string Text { get; set; } [Parameter] public string ButtonType { get; set; } = "button"; protected override void Render(HtmlTextWriter writer) { writer.AddAttribute(HtmlTextWriterAttribute.Type, ButtonType); writer.RenderBeginTag(HtmlTextWriterTag.Button); writer.Write(Text); writer.RenderEndTag(); } }
Pattern 3: AddAttributesToRender (Custom Attributes)
Add data attributes, ARIA attributes, or other custom attributes to the outer tag:
csharppublic class DataPanel : WebControl { [Parameter] public string DataId { get; set; } protected override HtmlTextWriterTag TagKey => HtmlTextWriterTag.Div; protected override void AddAttributesToRender(HtmlTextWriter writer) { base.AddAttributesToRender(writer); // ID, CssClass, Style, ToolTip, Enabled writer.AddAttribute("data-panel-id", DataId); writer.AddAttribute("role", "region"); } protected override void RenderContents(HtmlTextWriter writer) { writer.Write("Panel content here"); } }
Pattern 4: CompositeControl (Child Controls)
For controls that compose multiple child controls:
csharppublic class SearchBox : CompositeControl { [Parameter] public string Placeholder { get; set; } = "Search..."; protected override void Render(HtmlTextWriter writer) { writer.AddAttribute(HtmlTextWriterAttribute.Class, "search-container"); writer.RenderBeginTag(HtmlTextWriterTag.Div); writer.AddAttribute(HtmlTextWriterAttribute.Type, "text"); writer.AddAttribute(HtmlTextWriterAttribute.Placeholder, Placeholder); writer.RenderBeginTag(HtmlTextWriterTag.Input); writer.RenderEndTag(); writer.AddAttribute(HtmlTextWriterAttribute.Type, "submit"); writer.RenderBeginTag(HtmlTextWriterTag.Button); writer.Write("Go"); writer.RenderEndTag(); writer.RenderEndTag(); // div } }
Migration Steps
Step 1: Change the Using
diff- using System.Web.UI; - using System.Web.UI.WebControls; + using BlazorWebFormsComponents.CustomControls; + using Microsoft.AspNetCore.Components;
Step 2: Add [Parameter] to Public Properties
diff+ [Parameter] public string Title { get; set; } + [Parameter] public bool ShowHeader { get; set; } = true;
Step 3: Remove Web Forms-Only Members
Remove or stub these if present (they have no Blazor equivalent):
| Member | Action |
|---|---|
ViewState property bags | Replace with private fields |
CreateChildControls() | Move logic to Render or RenderContents |
EnsureChildControls() | Remove (no lazy initialization needed) |
INamingContainer | Remove (Blazor handles scoping differently) |
IPostBackDataHandler | Remove (use Blazor events instead) |
IPostBackEventHandler | Remove (use EventCallback) |
Step 4: Verify Rendering
The control should render identical HTML. Test with bUnit:
razor@inherits BlazorWebFormsTestContext @code { [Fact] public void MyControl_RendersExpectedHtml() { var cut = Render(@<MyControl Title="Hello" />); cut.Markup.ShouldContain("<div"); cut.Markup.ShouldContain("Hello"); } }
Available Base Classes
| BWFC Class | Inherits | Use When |
|---|---|---|
WebControl | BaseStyledComponent | Controls with RenderContents/HtmlTextWriter rendering |
CompositeControl | WebControl | Controls that compose multiple child elements |
UserControl | BaseStyledComponent | ASCX code-behind classes (markup-driven) |
Control | BaseWebFormsComponent | Bare controls with no styling |
HtmlTextWriter API Reference
The BWFC HtmlTextWriter shim supports:
| Method | Description |
|---|---|
Write(string) | Write raw text |
WriteLine(string) | Write text + newline |
RenderBeginTag(HtmlTextWriterTag) | Open an HTML element |
RenderBeginTag(string) | Open an HTML element by name |
RenderEndTag() | Close the current element |
AddAttribute(string, string) | Add attribute to next tag |
AddAttribute(HtmlTextWriterAttribute, string) | Add attribute by enum |
AddStyleAttribute(string, string) | Add inline style to next tag |
AddStyleAttribute(HtmlTextWriterStyle, string) | Add style by enum |
All standard HtmlTextWriterTag, HtmlTextWriterAttribute, and HtmlTextWriterStyle enum values are supported.
Inherited Properties (from BaseStyledComponent)
These work automatically on all WebControl-derived components:
| Property | Renders As |
|---|---|
CssClass | class="..." |
ID | id="..." |
ToolTip | title="..." |
Enabled="false" | disabled="disabled" |
Visible="false" | No output |
Style | style="..." |
What Does NOT Work
| Feature | Reason | Alternative |
|---|---|---|
Page.Controls.Add(ctrl) | No dynamic control tree in Blazor | Use RenderFragment or DynamicComponent |
ViewState["key"] | No ViewState persistence | Use private fields or cascading parameters |
PostBack events | No postback in Blazor | Use EventCallback or Blazor events |
Designer support | No designer in Blazor | N/A |
Async in RenderContents | Runs synchronously in BuildRenderTree | Fetch data in lifecycle, render from fields |
CLI Automation
The webforms-to-blazor CLI automatically:
- Detects
.csfiles inheritingSystem.Web.UI.WebControls.WebControl - Strips the
System.Web.UInamespace prefix → bareWebControl - Adds
using BlazorWebFormsComponents.CustomControls; - Injects
@inherits WebControlinto paired.razorfiles - Adds
[Parameter]to public properties
Checklist
For each custom WebControl being migrated:
- Using changed from
System.Web.UI.WebControls→BlazorWebFormsComponents.CustomControls -
using Microsoft.AspNetCore.Components;added - Public properties have
[Parameter]attribute -
ViewStateusage replaced with private fields -
CreateChildControls()logic moved toRender/RenderContents -
IPostBackDataHandler/IPostBackEventHandlerremoved - Control renders expected HTML (verified with bUnit test)
- Control usable from
.razorpages:<MyControl Property="value" />

