Migrate custom ASP.NET Web Forms server controls (WebControl, CompositeControl) to Blazor using BlazorWebFormsComponents. Covers RenderContents/HtmlTextWriter preservation, TagKey mapping, AddAttributesToRender, CreateChildControls, and the one-line-change migration pattern. WHEN: 'migrate custom control', 'webcontrol to blazor', 'rendercontents migration', 'htmltextwriter blazor', 'custom server control'. FOR SINGLE OPERATIONS: use /bwfc-ascx-migration for .ascx user controls, /bwfc-migration for full page migration.
67
84%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
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.
// Before (Web Forms)
using System.Web.UI.WebControls;
// After (Blazor + BWFC)
using BlazorWebFormsComponents.CustomControls;The BWFC WebControl class overrides Blazor's BuildRenderTree:
HtmlTextWriter instance (backed by StringBuilder)AddAttributesToRender(writer) — emits ID, class, style, tooltip, disabledRender(writer) → RenderBeginTag → RenderContents → RenderEndTagwriter.GetHtml()builder.AddMarkupContent(0, html)Your imperative rendering code produces the exact same HTML it did in Web Forms.
The classic Web Forms pattern — override TagKey for the outer element, RenderContents for the inner HTML:
using 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();
}
}For controls that need complete control over output:
public 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();
}
}Add data attributes, ARIA attributes, or other custom attributes to the outer tag:
public 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");
}
}For controls that compose multiple child controls:
public 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
}
}- using System.Web.UI;
- using System.Web.UI.WebControls;
+ using BlazorWebFormsComponents.CustomControls;
+ using Microsoft.AspNetCore.Components;+ [Parameter]
public string Title { get; set; }
+ [Parameter]
public bool ShowHeader { get; set; } = true;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) |
The control should render identical HTML. Test with bUnit:
@inherits BlazorWebFormsTestContext
@code {
[Fact]
public void MyControl_RendersExpectedHtml()
{
var cut = Render(@<MyControl Title="Hello" />);
cut.Markup.ShouldContain("<div");
cut.Markup.ShouldContain("Hello");
}
}| 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 |
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.
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="..." |
| 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 |
The webforms-to-blazor CLI automatically:
.cs files inheriting System.Web.UI.WebControls.WebControlSystem.Web.UI namespace prefix → bare WebControlusing BlazorWebFormsComponents.CustomControls;@inherits WebControl into paired .razor files[Parameter] to public propertiesFor each custom WebControl being migrated:
System.Web.UI.WebControls → BlazorWebFormsComponents.CustomControlsusing Microsoft.AspNetCore.Components; added[Parameter] attributeViewState usage replaced with private fieldsCreateChildControls() logic moved to Render/RenderContentsIPostBackDataHandler/IPostBackEventHandler removed.razor pages: <MyControl Property="value" />FritzAndFriends/BlazorWebFormsComponents
3154758
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.