Tag Helpers & View Components
Key Points
- Tag helpers are server-side attributes/elements that produce HTML — write
<form asp-controller="Home" asp-action="Index">instead of HTML helpers (@Html.BeginForm(...)). - View components are reusable view fragments with their own logic — like a mini-controller producing partial HTML. Use for headers, navigation, dynamic widgets.
- Built-in tag helpers for forms, links, validation, environment-conditional rendering, image cache-busting, and more.
- Custom tag helpers subclass
TagHelperand overrideProcess/ProcessAsync. ViewComponentvs partial view vs section: partial = static include; section = layout extension; view component = logic + render.
Concepts (deep dive)
Built-in tag helpers — common ones
<!-- Form helpers -->
<form asp-controller="Account" asp-action="Login" method="post">
<input asp-for="Email" />
<span asp-validation-for="Email"></span>
<input asp-for="Password" type="password" />
<button type="submit">Sign in</button>
</form>
<!-- Link / anchor helpers -->
<a asp-page="/Orders/Index" asp-route-id="@Model.Id">View order</a>
<!-- Cache-busting on static files -->
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
<!-- Environment-conditional content -->
<environment include="Development">
<link rel="stylesheet" href="~/lib/bootstrap/dist/bootstrap.css" />
</environment>
<environment exclude="Development">
<link rel="stylesheet" href="~/lib/bootstrap/dist/bootstrap.min.css" asp-append-version="true" />
</environment>
asp-for is the workhorse — generates name, id, value, validation attributes from the model property.
asp-append-version="true" adds a hash query string to the URL — browsers re-fetch when the file changes; can serve with long cache headers.
Validation tag helpers
<form asp-controller="Account" asp-action="Login">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<input asp-for="Email" />
<span asp-validation-for="Email" class="text-danger"></span>
</form>
@section Scripts {
@{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); }
}
asp-validation-for reads ModelState errors for the property; asp-validation-summary aggregates form-level errors. Combined with _ValidationScriptsPartial (jQuery validate), you get both server-side and client-side validation.
Custom tag helpers
[HtmlTargetElement("alert")]
public class AlertTagHelper : TagHelper
{
public string? Type { get; set; } = "info";
public override void Process(TagHelperContext ctx, TagHelperOutput output)
{
output.TagName = "div";
output.Attributes.SetAttribute("class", $"alert alert-{Type}");
}
}
Register globally in _ViewImports.cshtml:
Use:
<alert type="warning">Be careful!</alert>
<!-- Renders: <div class="alert alert-warning">Be careful!</div> -->
For async operations or content access, use ProcessAsync:
public override async Task ProcessAsync(TagHelperContext ctx, TagHelperOutput output)
{
var content = await output.GetChildContentAsync();
output.Content.SetHtmlContent(content.GetContent().ToUpper());
}
View components
public class CartViewComponent(ICartService cart) : ViewComponent
{
public async Task<IViewComponentResult> InvokeAsync()
{
var cart = await cart.GetCurrentAsync();
return View(cart);
}
}
Invoke from a view:
@await Component.InvokeAsync("Cart")
<!-- Or, with parameters: -->
@await Component.InvokeAsync("Search", new { query = "abc" })
Or via tag helper syntax (after registering in _ViewImports.cshtml):
View component vs partial vs section
| Construct | Use case |
|---|---|
Partial view (@await Html.PartialAsync("_Foo")) | Static template re-use; same data passed in |
Section (@section Scripts { ... }) | Layout extension points |
| View component | Self-contained widget with its own data fetch and logic |
Use a partial if it's "just markup with a model passed in". Use a view component if the widget needs to fetch its own data, query a service, etc. Use a section to inject scripts/styles into a layout slot.
IsRequired, Order, lifecycle attributes
[HtmlTargetElement("img", Attributes = "src,asp-append-version")]
public class CacheBustingImageTagHelper : TagHelper
{
public int Order => -1000; // run before built-in helpers
[HtmlAttributeName("src")] public string Src { get; set; } = "";
public bool AspAppendVersion { get; set; }
public override void Process(TagHelperContext ctx, TagHelperOutput output)
{
if (AspAppendVersion) /* compute version, append to src */;
}
}
Order controls execution order when multiple tag helpers target the same element.
Component vs tag helper
In Blazor, components (<MyComponent />) are interactive. In MVC/Razor Pages, tag helpers (and view components via <vc:foo />) are server-rendered. Blazor and MVC tag helpers don't mix in a single rendering pass — Blazor's render mode is separate.
Code: correct vs wrong
❌ Wrong: typing names manually
<input name="Email" id="Email" value="@Model.Email" />
<span data-valmsg-for="Email" class="text-danger"></span>
✅ Correct: tag helpers do it for you
❌ Wrong: hand-rolling cache busting
<link rel="stylesheet" href="/css/[email protected]" />
✅ Correct: asp-append-version
❌ Wrong: partial view doing data fetching
@{ var data = await ServiceLocator.Get<IFoo>().GetAsync(); }
@Html.PartialAsync("_FooPartial", data)
✅ Correct: view component
Design patterns for this topic
Pattern 1 — "Tag helpers over HTML helpers"
- Intent: cleaner Razor; HTML-shaped syntax.
Pattern 2 — "View component for self-contained widgets"
- Intent: logic + render in one place.
Pattern 3 — "Custom tag helpers for repeated markup"
- Intent: turn
<div class="alert alert-info">boilerplate into<alert type="info">.
Pattern 4 — "asp-append-version + long cache headers"
- Intent: static-asset cache busting without manual versioning.
Pros & cons / trade-offs
| Construct | Pros | Cons |
|---|---|---|
| Tag helpers | HTML-shaped; IDE-friendly | Hidden compile-time magic |
| View components | Self-contained widgets | More code than partials |
| Partials | Simple | No own logic |
| Custom tag helpers | DRY markup | Not Blazor-compatible |
When to use / when to avoid
- Use tag helpers instead of HTML helpers (
@Html.*) in modern code. - Use view components for widgets needing their own data.
- Use partials for pure template reuse with passed-in data.
- Avoid mixing partials and view components for the same purpose — pick one based on whether the widget owns its data.
Interview Q&A
Q1. Difference between tag helpers and HTML helpers? Tag helpers look like HTML attributes (<input asp-for="Email" />); HTML helpers are method calls (@Html.TextBoxFor(m => m.Email)). Tag helpers are preferred — better IDE support, more HTML-like.
Q2. What's asp-for? Binds a model property to a form input. Generates name, id, validation attributes.
Q3. What's a view component? A reusable view fragment with its own logic and view template — InvokeAsync() method does the data fetch, returns IViewComponentResult. Mini-controller for partial output.
Q4. When do you choose a view component over a partial? View component when the widget needs its own data fetching. Partial when you just pass in a model.
Q5. What does <environment> tag helper do? Conditionally renders content based on IHostEnvironment.EnvironmentName. Useful for dev-vs-prod asset bundles.
Q6. How do you create a custom tag helper? Subclass TagHelper; override Process/ProcessAsync. Register in _ViewImports.cshtml with @addTagHelper *, AssemblyName.
Q7. What's asp-append-version="true" for? Appends a hash of the file content to the URL — cache-busts when the file changes. Combine with long cache headers.
Q8. How do view components receive parameters? InvokeAsync(parameter1, parameter2, ...) — the framework binds parameters from the call.
Q9. Where does the view component view live? Views/Shared/Components/{ComponentName}/Default.cshtml (or {ViewName}.cshtml if you specify).
Q10. Tag helpers on Blazor components? Blazor uses components, not tag helpers. They don't interchange — render modes separate them.
Gotchas / common mistakes
- ⚠️ Forgetting
_ViewImports.cshtmlto register tag helpers — they don't activate. - ⚠️ Custom tag helper not registered — silent fallback to literal HTML.
- ⚠️ View component bypassing controller filters — auth must be at component or middleware level.
- ⚠️ Heavy data fetch in a header view component — loads on every page.
- ⚠️ Partial view doing data fetching — should be a view component instead.