Modern enterprise Blazor apps often suffer from form sprawl: dozens of pages, nearly identical form logic, endless <MudTextField> and <InputText> clones…
- In this blog, I will show a structure to help solving that problem:
- A metadata-driven dynamic form engine in Blazor.
- Forms defined by C# attributes + a single dynamic component.
- Reusable, elegant, and production-ready.
Below is the full architecture with example code you can run today.
🎛️ Goal
For example, we want to define a model like this:
public class CustomerInfo
{
[FormField("Full Name", InputType.Text, IsRequired = true)]
public string FullName { get; set; }
[FormField("Age", InputType.Number)]
public int Age { get; set; }
[FormField("Email Address", InputType.Email, IsRequired = true)]
public string Email { get; set; }
[FormField("Gender", InputType.Select, Options = new[] { "Male", "Female", "Other" })]
public string Gender { get; set; }
}
…and have Blazor render the correct UI automatically.
Step 1 — The Custom Attribute
public enum InputType
{
Text,
Number,
Email,
Select
}
[AttributeUsage(AttributeTargets.Property)]
public class FormFieldAttribute : Attribute
{
public string Label { get; }
public InputType InputType { get; }
public bool IsRequired { get; set; }
public string[]? Options { get; set; }
public FormFieldAttribute(string label, InputType type)
{
Label = label;
InputType = type;
}
}
Step 2 — A Dynamic Form Component
This component will:
- read metadata via reflection
- loop through properties
- render the correct input
- bind values into the model
DynamicForm.razor
@typeparam TModel
<EditForm Model="@Model">
<DataAnnotationsValidator />
<ValidationSummary />
@foreach (var field in Fields)
{
<div class="mb-3">
@RenderField(field)
</div>
}
<button type="submit" class="btn btn-primary">Submit</button>
</EditForm>
@code {
[Parameter] public TModel Model { get; set; }
private List<FieldMetadata> Fields { get; set; } = new();
protected override void OnInitialized()
{
Fields = typeof(TModel)
.GetProperties()
.Select(prop => new {
Prop = prop,
Attr = prop.GetCustomAttribute<FormFieldAttribute>()
})
.Where(x => x.Attr != null)
.Select(x => new FieldMetadata(x.Prop, x.Attr))
.ToList();
}
private RenderFragment RenderField(FieldMetadata f) => builder =>
{
var seq = 0;
var value = f.Property.GetValue(Model);
builder.OpenElement(seq++, "label");
builder.AddAttribute(seq++, "class", "form-label fw-bold");
builder.AddContent(seq++, f.Attribute.Label);
builder.CloseElement();
switch (f.Attribute.InputType)
{
case InputType.Text:
case InputType.Email:
builder.OpenElement(seq++, "input");
builder.AddAttribute(seq++, "class", "form-control");
builder.AddAttribute(seq++, "type", f.Attribute.InputType.ToString().ToLower());
builder.AddAttribute(seq++, "value", BindConverter.FormatValue(value));
builder.AddAttribute(seq++, "onchange", EventCallback.Factory.CreateBinder(
this,
v => f.Property.SetValue(Model, v),
value));
builder.CloseElement();
break;
case InputType.Number:
builder.OpenElement(seq++, "input");
builder.AddAttribute(seq++, "class", "form-control");
builder.AddAttribute(seq++, "type", "number");
builder.AddAttribute(seq++, "value", BindConverter.FormatValue(value));
builder.AddAttribute(seq++, "onchange", EventCallback.Factory.CreateBinder(
this,
v => f.Property.SetValue(Model, Convert.ToInt32(v)),
value));
builder.CloseElement();
break;
case InputType.Select:
builder.OpenElement(seq++, "select");
builder.AddAttribute(seq++, "class", "form-select");
builder.AddAttribute(seq++, "value", BindConverter.FormatValue(value));
builder.AddAttribute(seq++, "onchange", EventCallback.Factory.CreateBinder(
this,
v => f.Property.SetValue(Model, v),
value));
foreach (var opt in f.Attribute.Options ?? Array.Empty<string>())
{
builder.OpenElement(seq++, "option");
builder.AddAttribute(seq++, "value", opt);
builder.AddContent(seq++, opt);
builder.CloseElement();
}
builder.CloseElement();
break;
}
};
}
FieldMetadata.cs
public class FieldMetadata
{
public PropertyInfo Property { get; }
public FormFieldAttribute Attribute { get; }
public FieldMetadata(PropertyInfo prop, FormFieldAttribute attr)
{
Property = prop;
Attribute = attr;
}
}
Step 3 — Using the Dynamic Form
CustomerPage.razor
@page "/customer"
@code {
private CustomerInfo Model = new();
}
<h3>Customer Registration</h3>
<DynamicForm TModel="CustomerInfo" Model="@Model" />
Boom. Entire form, no UI repetition, fully dynamic.
Bonus: MudBlazor Integration
Replace the internal switch with:
<MudTextField @bind-Value="@value" Label="@f.Attribute.Label" />
or:
<MudSelect T="string" @bind-Value="@value">
@foreach (var opt in f.Attribute.Options)
{
<MudSelectItem T="string" Value="@opt">@opt</MudSelectItem>
}
</MudSelect>
Suddenly you have a dynamic MudBlazor form generator.
Conclusion
This advanced blazor technique lets you:
- define forms once
- generate UI dynamically
- eliminate repetitive Razor
- create CMS-driven, metadata-driven, dynamic form engines
It’s a clean way to bring declarative UI magic into Blazor without sacrificing type-safety.