ASP.NET Core Tag Helper'lar: Yerleşik Tag Helper'lar ve Kendi Tag Helper'ını Yazmak
Tag helper, Razor'daki HTML etiketini sunucuda işleyen ve derleme anında bağlanan bir C# class'ıdır; asp-for tek başına id, name, label, input tipi ve data-val attribute'larını üretir. Yerleşik anchor, form, input, select, environment ve cache tag helper'larının gerçek çıktısını, HTML Helper farkını ve enum attribute'unda @ unutunca kırılan build'i görüp kendi <status-badge> ve yetkiye göre gizlenen <secure-link> tag helper'larımızı yazıyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.402) ve Microsoft.EntityFrameworkCore.Sqlite 10.0.14 ile derlenip denenmiştir.
Önce Tahmin Et
Kitap durumunu renkli bir rozet olarak gösteren kendi tag helper'ımızı yazdık. Status property'si BookStatus enum'u. Admin listesinde iki yazım deniyorsun: <status-badge status="book.Status" /> ve <status-badge status="@book.Status" />. İkisi de derlenir mi? Bir de şu: yalnız yöneticilere görünen bir menü link'i için <secure-link> tag helper'ı yazdık. Giriş yapmamış bir kullanıcı sayfayı açtığında HTML'de (HyperText Markup Language) o link'ten geriye ne kalır?
Tag Helper Ne Yapar?
Tag helper, Razor view'ındaki bir HTML etiketini sunucu tarafında işleyen bir C# class'ı. View'da <a asp-controller="Books" asp-action="Details" asp-route-id="2"> yazarsın; tarayıcıya <a href="/Books/Details/2"> gider. asp- ile başlayan attribute'lar HTML'e hiç ulaşmaz, onları AnchorTagHelper tüketir. Bunun olması için view'ın _ViewImports.cshtml'deki @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers satırını görmesi gerekir; 2. yazıda bu satır olmayınca link'lerin href'siz kaldığını görmüştük.
Tag helper'lar derleme anında bağlanır. Razor derleyicisi her etiketin hangi tag helper'larla eşleştiğini build sırasında çözer, attribute değerlerini property'lere bağlayan C# kodunu üretir. Yani asp-for="Titel" gibi bir yazım hatası runtime'da değil, dotnet build'de yakalanır. Bu yazıda yerleşik tag helper'ların gerçekte ne ürettiğine bakacağız; sonra iki tane de kendimiz yazacağız.
Anchor ve Form Tag Helper'ları
Anchor tag helper'ın attribute'ları routing'in diliyle konuşur: asp-area, asp-controller, asp-action, asp-route-{ad} (her biri bir route değeri), asp-fragment ve doğrudan bir route adı için asp-route. Belirtmediğin değerleri mevcut istekten alır; 2. yazıdaki "ambient value" davranışı buydu. Route template'te karşılığı olmayan bir asp-route-* değeri query string'e düşer.
Form tag helper'ı ise iki iş yapar. Admin area'sındaki yeni kitap formunun açılış etiketi:
<form asp-action="Create" method="post">
Gerçek çıktının ilk ve son satırı:
<form method="post" action="/Admin/Books/Create">
...
<input name="__RequestVerificationToken" type="hidden" value="CfDJ8EINq_wm4TFCv7guzy6SHCfzzkOYyIR1Sp4mE3EoNBQjA6BL_QWgAjk2pVGHhe1h8Mj8mVLucy3EHMoeOEqCK4WtHPfzHKpX5f4q1OLbcHOk5kEauCfipliN1vOMCdB9VzD5UKjhXMR6CFDL28eJteU" /></form>
action URL'i (Uniform Resource Locator) routing'den üretildi ve method="post" olan her forma, biz istemeden, bir antiforgery token'ı eklendi. Bu token'ın neyi koruduğu, doğrulamanın nasıl açıldığı ve JavaScript'in fetch ile attığı isteklerde nasıl gönderildiği bir sonraki yazının konusu.
Input, Label, Select ve Validation Tag Helper'ları
Formun asıl işi asp-for ile yapılır. Form, admin area'sının Create action'ının döndüğü BookFormViewModel'e bağlı:
public class BookFormViewModel
{
public int? Id { get; set; }
[Required]
[StringLength(120)]
public string Title { get; set; } = "";
[Required]
[Display(Name = "ISBN")]
[RegularExpression(@"^\d{13}$", ErrorMessage = "ISBN must be 13 digits.")]
public string Isbn { get; set; } = "";
[Range(1, 10000)]
public decimal Price { get; set; }
[Range(0, 1000)]
public int Stock { get; set; }
[Display(Name = "Published on")]
[DataType(DataType.Date)]
public DateOnly PublishedOn { get; set; } = DateOnly.FromDateTime(DateTime.Today);
public Genre Genre { get; set; }
[Display(Name = "Author")]
public int AuthorId { get; set; }
public IEnumerable<SelectListItem> Authors { get; set; } = [];
}
View'dan birkaç alan:
<div class="mb-2">
<label asp-for="Isbn"></label>
<input asp-for="Isbn" class="form-control" />
<span asp-validation-for="Isbn" class="text-danger"></span>
</div>
<div class="mb-2">
<label asp-for="Stock"></label>
<input asp-for="Stock" class="form-control" />
</div>
<div class="mb-2">
<label asp-for="PublishedOn"></label>
<input asp-for="PublishedOn" class="form-control" />
</div>
<div class="mb-2">
<label asp-for="Genre"></label>
<select asp-for="Genre" asp-items="Html.GetEnumSelectList<Genre>()" class="form-select"></select>
</div>
<div class="mb-2">
<label asp-for="AuthorId"></label>
<select asp-for="AuthorId" asp-items="Model.Authors" class="form-select">
<option value="">-- choose --</option>
</select>
</div>
Ve ürettikleri gerçek HTML:
<label for="Isbn">ISBN</label>
<input class="form-control" type="text" data-val="true" data-val-regex="ISBN must be 13 digits." data-val-regex-pattern="^\d{13}$" data-val-required="The ISBN field is required." id="Isbn" name="Isbn" value="" />
<span class="text-danger field-validation-valid" data-valmsg-for="Isbn" data-valmsg-replace="true"></span>
<label for="Stock">Stock</label>
<input class="form-control" type="number" data-val="true" data-val-range="The field Stock must be between 0 and 1000." data-val-range-max="1000" data-val-range-min="0" data-val-required="The Stock field is required." id="Stock" name="Stock" value="0" /><input name="__Invariant" type="hidden" value="Stock" />
<label for="PublishedOn">Published on</label>
<input class="form-control" type="date" data-val="true" data-val-required="The Published on field is required." id="PublishedOn" name="PublishedOn" value="2026-11-08" /><input name="__Invariant" type="hidden" value="PublishedOn" />
<label for="Genre">Genre</label>
<select class="form-select" data-val="true" data-val-required="The Genre field is required." id="Genre" name="Genre"><option selected="selected" value="0">Fiction</option>
<option value="1">Science</option>
<option value="2">History</option>
<option value="3">Programming</option>
</select>
<label for="AuthorId">Author</label>
<select class="form-select" data-val="true" data-val-required="The Author field is required." id="AuthorId" name="AuthorId">
<option value="">-- choose --</option>
<option value="1">Ada Lane</option>
<option value="2">Alan Brooks</option>
<option value="3">Grace Hill</option>
</select>
Tek bir asp-for buradan çok şey üretti. id ve name, model binding'in POST'ta geri okuyacağı adla aynı. label, [Display(Name = "ISBN")]'den metnini aldı. DataAnnotations attribute'ları, client-side validation kütüphanesinin okuyacağı data-val-* attribute'larına çevrildi; mesajlar bile hazır. type property'nin tipinden çıkarıldı: string için text, int için number, [DataType(DataType.Date)] işaretli DateOnly için date. Bu attribute olmadan DateOnly alanı type="text" ve value="11/8/2026" olarak render edildi; tarih seçici yok ve değer sunucunun kültürüne bağlı. decimal Price ise type="text" ve data-val-number ile geliyor; bunun Türkçe kültürde yarattığı "1,5" ile "1.5" sorununu 7. yazıda konuşacağız.
Gözden kaçabilecek bir ayrıntı: number ve date input'larının yanına <input name="__Invariant" type="hidden" value="Stock" /> eklendi. Tarayıcılar bu input tiplerinin değerini her zaman kültürden bağımsız biçimde (1234.5, 2026-11-08) gönderir; bu gizli alan da model binding'e "bu alanı invariant culture ile parse et" der. Sunucu Türkçe kültürde çalışsa bile 2026-11-08 doğru okunur.
Select tarafında asp-items bir IEnumerable<SelectListItem> bekler. Yazarları controller'dan ViewModel'le getirdik; enum için Html.GetEnumSelectList<Genre>() enum değerlerini (varsa [Display] adlarıyla) listeye çevirir. Elle yazdığın <option value=""> korunur ve listenin başında kalır. Validation tarafındaki iki tag helper'dan asp-validation-for tek alanın, asp-validation-summary ise birden fazla hatanın mesajını basar; ikisini 7. yazıda hatalı bir POST ile çalışırken göreceğiz.
Environment, Script, Image ve Cache Tag Helper'ları
Template'in layout'unda <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" /> satırı var. Ürettiği gerçek çıktı <link rel="stylesheet" href="/css/site.css?v=j6fhJSuuyLpOSLuPJU0TsDV0iNjor5S3rDnvxJrt4bg" />. v değeri dosya içeriğinin hash'i; dosya değişince URL de değişir ve tarayıcı cache'i kendiliğinden geçersiz olur. Aynısı <img> ve <script> için de çalışıyor:
<img src="~/favicon.ico" asp-append-version="true" alt="" />
<environment include="Development"><p class="text-warning">Development build</p></environment>
<img src="/favicon.ico?v=Jtxf9L-5ITKRc1gIRl4VbUpGkRNfOBXjYTdhJD4facM" alt="" />
<p class="text-warning">Development build</p>
~/ uygulamanın köküne çözülür. <environment> etiketi kendisi çıktıya yazılmaz; içi yalnız belirtilen ortamda render edilir. Uygulamayı ASPNETCORE_ENVIRONMENT=Production ile başlattığımda aynı sayfada "Development build" metni 0 kez geçiyordu. .NET 9'dan beri static asset'ler MapStaticAssets ile fingerprint'li sunuluyor; bunun asp-append-version ile ilişkisi bu serinin 9. yazısında.
<cache> tag helper'ı içindeki render edilmiş HTML'i sunucu belleğinde tutar. Geçen yazıda layout'taki sepet özetinin her sayfaya bir sorgu eklediğini görmüştük. Onu sarmaladım:
<cache vary-by-cookie="cart" expires-after="@TimeSpan.FromSeconds(30)">
<vc:cart-summary />
</cache>
Aynı cart=1-4-4 cookie'siyle üç istek, sonra farklı cookie'yle bir istek atıp Entity Framework Core (EF Core) komut log'undan sorgu sayılarını okudum:
req 1 queries: 3
req 2 queries: 2
req 3 queries: 2
other cookie queries: 3
İkinci ve üçüncü istekte view component hiç çalışmadı; HTML bellekten geldi. vary-by-cookie cache key'ine cookie'nin değerini ekledi, bu yüzden farklı sepet farklı bir kayıt oldu. Önemli bir sınır var: cache tag helper yalnızca kendi içinde olanı atlar. Action'ın controller'da yaptığı sorgular yine çalışır; o yüzden en çok, kendi verisini getiren bir view component'i sardığında kazandırır. <distributed-cache> aynı işi IDistributedCache (Redis gibi paylaşılan bir cache) üzerinden yapar ve birden fazla sunucu arasında paylaşılır; name attribute'u zorunludur.
HTML Helper ile Farkı ve Tag Helper'ları Kapatmak
ASP.NET MVC (Model-View-Controller) 5'ten gelen kod tabanlarında aynı işi HTML Helper'lar yapar. Aynı view'a ikisini yan yana yazdım:
@Html.TextBoxFor(m => m.Title, new { @class = "form-control" })
@Html.ActionLink("Back to list", "Index")
<a asp-action="Index">Back to list</a>
<!a asp-action="Index">Back to list</!a>
<input class="form-control" data-val="true" data-val-length="The field Title must be a string with a maximum length of 120." data-val-length-max="120" data-val-required="The Title field is required." id="Title" maxlength="120" name="Title" type="text" value="" />
<a href="/Admin/Books">Back to list</a>
<a href="/Admin/Books">Back to list</a>
<a asp-action="Index">Back to list</a>
Çıktı aynı; fark yazımda. HTML Helper bir C# method çağrısı: CSS (Cascading Style Sheets) class'ı için new { @class = ... } gibi anonim object'ler, data-* attribute'ları için alt çizgi hileleri gerekir ve tasarımcı bu satırı HTML olarak okuyamaz. Tag helper ise HTML'in kendisi; editör onu HTML olarak renklendirir, attribute'lar olduğu gibi kalır, yalnız asp-* kısmı sunucuda işlenir. Yeni kodda tag helper, MVC 5'ten taşınan view'larda HTML Helper'ı hemen değiştirmek zorunda değilsin; ikisi aynı view'da sorunsuz yaşar.
Son satır bir kaçış: etiket adının önündeki ! o etiket için tag helper'ları kapatır ve asp-action HTML'e olduğu gibi gider. Bir klasörde ya da tek view'da belirli bir tag helper'ı kapatmak için @removeTagHelper, tag helper'ların yalnızca belirli bir önekle (<th:a> gibi) çalışmasını istersen @tagHelperPrefix kullanırsın.
Kendi Tag Helper'ını Yazmak
İlki basit: kitap durumunu Bootstrap rozetine çeviren <status-badge>.
using Bookstore.Web.Models;
using Microsoft.AspNetCore.Razor.TagHelpers;
namespace Bookstore.Web.TagHelpers;
[HtmlTargetElement("status-badge", TagStructure = TagStructure.WithoutEndTag)]
public class StatusBadgeTagHelper : TagHelper
{
public BookStatus Status { get; set; }
public override void Process(TagHelperContext context, TagHelperOutput output)
{
(string css, string text) = Status switch
{
BookStatus.Published => ("bg-success", "Published"),
BookStatus.OutOfPrint => ("bg-warning text-dark", "Out of print"),
_ => ("bg-secondary", "Draft"),
};
output.TagName = "span";
output.TagMode = TagMode.StartTagAndEndTag;
output.Attributes.SetAttribute("class", $"badge {css}");
output.Content.SetContent(text);
}
}
[HtmlTargetElement] hangi etiketle eşleşeceğini söyler; vermezsen class adından (StatusBadge → status-badge) çıkarılır. Public property'ler kebab-case attribute'lara bağlanır. Process içinde TagHelperOutput'u değiştiriyoruz: etiket adını span yapıyor, class ekliyor, içeriği yazıyoruz. SetContent metni HTML encode eder; HTML basmak istersen SetHtmlContent var, ama 3. yazıdaki Html.Raw uyarısı burada da geçerli.
Tahminin ilk kısmı. Admin listesinde <status-badge status="book.Status" /> yazdığımda build kırıldı:
Areas/Admin/Views/Books/Index.cshtml(12,39): error CS0117: 'BookStatus' does not contain a definition for 'book'
Razor, enum tipli bir property'ye verilen attribute değerini enum'un üyesi olarak okur: status="Published" yazarsan BookStatus.Published olur. Bu yüzden book.Status, BookStatus.book.Status'a dönüştü. Bir değişkeni vermek için @ gerekiyor: <status-badge status="@book.Status" />. string dışındaki diğer tiplerde (int, bool, kendi class'ların) değer zaten C# ifadesi olarak okunur, string property'lerde ise düz metin. Düzeltmeden sonra admin listesinin gerçek çıktısından üç satır:
<td><span class="badge bg-success">Published</span></td>
<td><span class="badge bg-warning text-dark">Out of print</span></td>
<td><span class="badge bg-secondary">Draft</span></td>
İkincisi daha gerçekçi: yalnız CanManageBooks policy'sini sağlayan kullanıcıya görünen bir link. Tag helper'lar DI (Dependency Injection) container'ından constructor ile service alabilir ve async çalışabilir:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
namespace Bookstore.Web.TagHelpers;
[HtmlTargetElement("secure-link")]
public class SecureLinkTagHelper(IAuthorizationService authorization, IUrlHelperFactory urlHelperFactory) : TagHelper
{
public required string Policy { get; set; }
public string? Area { get; set; }
public required string Controller { get; set; }
public string Action { get; set; } = "Index";
[ViewContext]
[HtmlAttributeNotBound]
public ViewContext ViewContext { get; set; } = null!;
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
AuthorizationResult result =
await authorization.AuthorizeAsync(ViewContext.HttpContext.User, Policy);
if (!result.Succeeded)
{
// Yetkisi olmayan kullanıcı link'i hiç görmez
output.SuppressOutput();
return;
}
IUrlHelper url = urlHelperFactory.GetUrlHelper(ViewContext);
output.TagName = "a";
output.Attributes.SetAttribute("href", url.Action(Action, Controller, new { area = Area ?? "" }));
}
}
[ViewContext] attribute'u framework'e "bu property'ye mevcut view'ın context'ini koy" der; [HtmlAttributeNotBound] da onun bir HTML attribute'u gibi görünmesini engeller. SuppressOutput() etiketi ve içeriğini tamamen siler. Layout'taki kullanım ve Program.cs'teki policy:
<li class="nav-item">
<secure-link policy="CanManageBooks" area="Admin" controller="Books" class="nav-link text-dark">Admin</secure-link>
</li>
builder.Services.AddAuthorization(options =>
options.AddPolicy("CanManageBooks", policy => policy.RequireRole("Admin")));
Authentication bu serinin konusu değil; denemek için yalnız Development'ta çalışan, bir header'ı kullanıcıya çeviren küçük bir middleware yazdım. Bunu gerçek bir uygulamaya koyma:
// Yalnız demo: X-Demo-Role header'ını bir kullanıcıya çevirir. Gerçek authentication, Authentication serisinde.
if (app.Environment.IsDevelopment())
{
app.Use(async (context, next) =>
{
if (context.Request.Headers.TryGetValue("X-Demo-Role", out StringValues role))
{
context.User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Role, role.ToString())], "Demo"));
}
await next();
});
}
curl -H "X-Demo-Role: Admin" ile gelen gerçek çıktı; tanımadığımız class attribute'u olduğu gibi korundu:
<li b-v51vsz6my3 class="nav-item">
<a class="nav-link text-dark" href="/Admin/Books">Admin</a>
</li>
X-Demo-Role: Editor ile ve header'sız isteklerde link yoktu. Tahminin ikinci kısmının cevabı, header'sız isteğin gerçek çıktısında:
<li b-v51vsz6my3 class="nav-item">
</li>
Link gitti ama onu saran <li> kaldı; menüde boş bir öğe ve fazladan bir boşluk. Tag helper yalnız kendi etiketinden sorumlu. Bunu çözmenin yolunu aşağıdaki denemelerde bulacaksın.
Üretimde Dikkat
Gizlemek yetkilendirme değildir. <secure-link> yalnızca kullanıcı deneyimi için; /Admin/Books adresini bilen biri onu doğrudan yazabilir. Asıl kontrol controller'da ya da area'da [Authorize(Policy = "CanManageBooks")] ile yapılmalı. Bunun ayrıntısı Web API (Application Programming Interface) serisinin Authorization yazısında ve ilerideki Authentication ve Authorization serisinde.
Tag helper'lar her render'da çalışır. SecureLinkTagHelper her sayfa görüntülemesinde bir policy değerlendiriyor; rol kontrolü gibi bellek içi bir policy için önemsiz, ama veritabanına giden bir requirement handler'ı her sayfaya bir sorgu ekler. Aynı şekilde <cache>'in bellek kullanımı sınırsız değildir; vary-by-* ile çok sayıda farklı key üretirsen (her kullanıcı için ayrı kayıt gibi) belleği büyütürsün. Varsayılan boyut sınırı ve öncelik ayarları CacheTagHelperOptions'ta.
Sık Düşülen Tuzaklar
Enum ya da başka bir property'ye değişken verirken @'i unutmak. Enum'da derleme hatası alırsın; ama string property'de title="book.Title" sessizce "book.Title" metnini basar.
@addTagHelper *, Bookstore.Web satırını eklemeden kendi tag helper'ını kullanmak. <status-badge> hiçbir hata vermeden HTML'e olduğu gibi gider.
Tag helper'da iş mantığı ya da veri erişimi. Tag helper sunum aracıdır; bir rozeti, bir link'i, bir etiketin attribute'larını değiştirir. Veri getirmesi gereken bir parça view component'tir.
<cache>'i kullanıcıya özel içeriğin etrafına vary-by-user ya da vary-by-cookie olmadan koymak. İlk kullanıcının sepeti, adı ya da yetkiye göre gizlenen link'leri 30 saniye boyunca herkese gösterilir.
Küçük Bir Deneme
★☆☆ BookStatus enum'unun üyelerine [Display(Name = "Out of print")] gibi adlar ver ve Html.GetEnumSelectList<BookStatus>() ile bir select oluştur. Option metinleri değişiyor mu?
★★☆ Boş <li> sorununu çöz: li etiketini hedefleyen ve secure-policy attribute'u taşıyan bir tag helper yaz ([HtmlTargetElement("li", Attributes = "secure-policy")]), policy sağlanmazsa bütün öğeyi bastırsın.
★★★ <status-badge>'e bir show-icon bool attribute'u ekle; true ise içeriğin başına SetHtmlContent ile küçük bir ikon <i>'si koysun. TagHelperOutput.PreContent ile Content arasındaki farkı dene ve Order property'sini değiştirip iki tag helper'ın aynı etiket üzerinde hangi sırayla çalıştığını gözlemle.
Tag helper'lar formumuzu kurdu: name'ler, data-val'ler ve bir antiforgery token'ı hazır. Şimdi bu formu göndermek gerekiyor. Bir sonraki yazıda model binding'in formdan gelen değerleri koleksiyonlara, iç içe object'lere ve checkbox'lara nasıl bağladığını, Post-Redirect-Get'i, antiforgery doğrulamasını ve entity'yi doğrudan bind etmenin açtığı overposting açığını gerçek isteklerle görüyoruz.