Built-in Tag Helpers cover forms, links, and caching. But what about your own components — an <alert> tag that renders Bootstrap alerts, a <gravatar> tag that generates profile images, or an <authorize> tag that hides content from unauthorized users? Custom Tag Helpers let you create your own HTML elements with server-side intelligence.
A Custom Tag Helper is a C# class that inherits from TagHelper and overrides the Process() or ProcessAsync() method. When the Razor engine encounters a matching HTML element, it invokes your class to transform, augment, or completely replace the rendered HTML output.
The class name determines the HTML tag. ASP.NET Core converts PascalCase to kebab-case:
AlertTagHelper → <alert>
GravatarImageTagHelper → <gravatar-image>
EmailSendButtonTagHelper → <email-send-button>
Or use [HtmlTargetElement("custom-name")] to override the convention.
// TagHelpers/AlertTagHelper.cs
using Microsoft.AspNetCore.Razor.TagHelpers;
namespace MyApp.TagHelpers
{
// Targets: <alert type="success">Message here</alert>
[HtmlTargetElement("alert")]
public class AlertTagHelper : TagHelper
{
// Public properties become HTML attributes automatically
public string Type { get; set; } = "info"; // alert type: success, danger, warning, info
public bool Dismissible { get; set; } = true; // show close button?
public string Icon { get; set; } // optional Font Awesome icon
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
// Change the rendered tag from <alert> to <div>
output.TagName = "div";
output.Attributes.SetAttribute("class",
$"alert alert-{Type}{(Dismissible ? " alert-dismissible fade show" : "")}");
output.Attributes.SetAttribute("role", "alert");
// Get the inner content written between <alert>...</alert>
var childContent = await output.GetChildContentAsync();
// Build the inner HTML
var html = "";
if (!string.IsNullOrEmpty(Icon))
html += $"";
html += childContent.GetContent();
if (Dismissible)
html += "";
output.Content.SetHtmlContent(html);
}
}
}
<!-- Usage in Razor Views — Clean, semantic HTML -->
<alert type="success" icon="fas fa-check">Product saved successfully!</alert>
<alert type="danger" dismissible="false">Critical error occurred.</alert>
<alert type="warning">Your session will expire in 5 minutes.</alert>
<!-- Renders as: -->
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="fas fa-check me-2"></i>Product saved successfully!
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
// TagHelpers/GravatarTagHelper.cs
using System.Security.Cryptography;
using System.Text;
[HtmlTargetElement("gravatar")]
public class GravatarTagHelper : TagHelper
{
public string Email { get; set; }
public int Size { get; set; } = 80;
public string Default { get; set; } = "identicon"; // mp, identicon, monsterid, retro
public override void Process(TagHelperContext context, TagHelperOutput output)
{
output.TagName = "img";
// Generate MD5 hash of email (Gravatar standard)
var emailHash = MD5.HashData(Encoding.ASCII.GetBytes(Email.Trim().ToLower()));
var hashString = Convert.ToHexStringLower(emailHash);
output.Attributes.SetAttribute("src",
$"https://www.gravatar.com/avatar/{hashString}?s={Size}&d={Default}");
output.Attributes.SetAttribute("alt", $"Avatar for {Email}");
output.Attributes.SetAttribute("class", "rounded-circle");
output.Attributes.SetAttribute("width", Size.ToString());
output.Attributes.SetAttribute("height", Size.ToString());
output.TagMode = TagMode.SelfClosing;
}
}
<!-- Usage -->
<gravatar email="@user.Email" size="48" />
<gravatar email="dev@toolliyo.com" size="120" default="retro" />
// TagHelpers/AuthorizeViewTagHelper.cs
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
[HtmlTargetElement("authorize-view")]
public class AuthorizeViewTagHelper : TagHelper
{
[ViewContext] // Injects current request context
[HtmlAttributeNotBound] // Prevents this from being an HTML attribute
public ViewContext ViewContext { get; set; }
public string Roles { get; set; } // Comma-separated roles: "Admin,Manager"
public override void Process(TagHelperContext context, TagHelperOutput output)
{
output.TagName = null; // Don't render the <authorize-view> tag itself
var user = ViewContext.HttpContext.User;
if (!user.Identity?.IsAuthenticated ?? true)
{
output.SuppressOutput(); // User not logged in — hide everything
return;
}
if (!string.IsNullOrEmpty(Roles))
{
var requiredRoles = Roles.Split(',', StringSplitOptions.TrimEntries);
if (!requiredRoles.Any(role => user.IsInRole(role)))
{
output.SuppressOutput(); // User doesn't have required role
return;
}
}
// User is authorized — render child content as-is
}
}
<!-- Usage: Only admins see the delete button -->
<authorize-view roles="Admin">
<button class="btn btn-danger">Delete All Records</button>
</authorize-view>
<!-- Only authenticated users see this -->
<authorize-view>
<a href="/dashboard">My Dashboard</a>
</authorize-view>
<!-- Views/_ViewImports.cshtml -->
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers <!-- Built-in -->
@addTagHelper *, MyApp <!-- YOUR custom Tag Helpers -->
[HtmlTargetElement] for custom element names that don't follow PascalCase conventionProcessAsync when accessing child content (GetChildContentAsync())Process for simple transformations without async operations[ViewContext] to access HttpContext, User, RouteData, and ModelStateSuppressOutput() to conditionally hide content instead of rendering empty tagsQ: "Can you explain the difference between Process() and ProcessAsync() in a Tag Helper?"
Answer: "Process() is synchronous — use it for simple transformations where you modify attributes, change the tag name, or set content without any I/O. ProcessAsync() is the async variant — it's required when you call output.GetChildContentAsync() to access the content nested between the opening and closing tags. In practice, I default to ProcessAsync because most real-world Tag Helpers need to inspect or modify their child content, and the async pattern prevents thread blocking in the ASP.NET Core pipeline."