Mert Özen Her Satırda Daha İleri
Backend ASP.NET Core MVC ve Razor Pages — #01

Server-Side Rendering Ne Zaman? MVC, Razor Pages, Blazor SSR ve Angular SPA Arasında Seçim

Mert Özen 8 Kas 2026 12 dk 2 görüntülenme
Server-Side Rendering Ne Zaman? MVC, Razor Pages, Blazor SSR ve Angular SPA Arasında Seçim

Server-side rendering; SEO, hızlı ilk yükleme, form ağırlıklı iç uygulama ve küçük ekip için güçlü, yoğun etkileşim ve çok istemcili API için SPA daha uygun. Aynı listeyi MVC controller'ı ve Razor Page ile yazıp framework log'larında isteğin yaşam döngüsünü izliyor, 474 byte'lık Angular kabuğunu 2,5 KB'lık hazır HTML ile karşılaştırıyor ve Blazor SSR'ın yerini tartışıyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.402) ile derlenip denenmiştir; karşılaştırmadaki Angular build'i Angular 22 ile alınmıştır.

Önce Tahmin Et

Elinde aynı kitap listesini gösteren iki uygulama var. Birincisi ASP.NET Core MVC (Model-View-Controller) ile yazılmış, sayfayı sunucuda üretiyor. İkincisi Angular ile yazılmış bir SPA (Single Page Application), veriyi bir Web API'den (Application Programming Interface) çekiyor. İkisine de terminalden curl ile istek atıyorsun, yani JavaScript çalıştırmayan bir istemciyle. Hangisinin yanıtında "Rivers of Ink" kitabının adı geçer? Peki tarayıcı, ilk isteğin yanıtını aldığında ekrana kitap listesini çizebilmek için kaç dosya daha indirmek zorunda kalır? Cevabı bir yere yaz, yazının ortasında gerçek çıktılarla karşılaştıracağız.

SSR Ne Demek, Bu Seride Ne Yapacağız?

SSR (Server-Side Rendering), HTML'in (HyperText Markup Language) sunucuda üretilip tarayıcıya hazır gönderilmesi demek. Tarayıcı gelen belgeyi okur, CSS'i (Cascading Style Sheets) uygular ve kullanıcı içeriği görür; sayfanın anlamlı hâle gelmesi için JavaScript'in çalışmasını beklemez. ASP.NET Core'da bunun iki klasik yolu var: MVC ve Razor Pages. İkisi de aynı Razor view motorunu, aynı routing'i, aynı model binding ve filter altyapısını kullanır. Razor Pages zaten MVC'nin üzerine kurulu.

Bu seride sitenin Bookstore uygulamasının sunucuda render edilen yüzünü yazacağız: halka açık bir katalog ve bir yönetim paneli. Controller/action temeli, attribute routing, model binding kaynakları ve filter'lar ASP.NET Core Web API serisinde anlatıldı; burada onları tekrar etmiyoruz, hep view ve form tarafından bakıyoruz. MVC pattern'inin kendisi, yani Smalltalk'taki orijinal hâliyle web'deki hâlinin farkı da UI (User Interface) Mimari Pattern'leri serisinin "MVC Pattern'i: Orijinal Smalltalk MVC ile Web MVC Aynı Şey Değil" yazısında. Bu yazının sorusu daha pratik: sunucuda render etmek ne zaman doğru seçim, ve bu seçimi yaptıysan MVC ile Razor Pages arasında nasıl karar verirsin?

Aynı Sayfa, İki Model

Önce iki modeli yan yana koyalım. Tek bir projede (Ssr01) aynı üç kitabı hem bir MVC controller'ıyla hem bir Razor Page ile listeliyoruz. Veri ortak:

namespace Ssr01.Models;

public record BookRow(int Id, string Title, decimal Price);

public static class BookData
{
    public static readonly BookRow[] All =
    [
        new(1, "The Pragmatic Path", 420m),
        new(2, "Rivers of Ink", 265.5m),
        new(3, "Clean Lines", 380m),
    ];
}

MVC tarafında isteği bir controller'ın action'ı karşılar ve hangi view'ın render edileceğine o karar verir:

using Microsoft.AspNetCore.Mvc;
using Ssr01.Models;

namespace Ssr01.Controllers;

public class BooksController : Controller
{
    public IActionResult Index() => View(BookData.All);
}
@model BookRow[]
<h1>Books (MVC)</h1>
<ul>
    @foreach (BookRow book in Model)
    {
        <li>@book.Title: @book.Price TL</li>
    }
</ul>

View(BookData.All) çağrısı view adını vermiyor; MVC action'ın adından (Index) ve controller'ın adından (Books) Views/Books/Index.cshtml dosyasını bulur. Razor Pages'te ise URL (Uniform Resource Locator) ile dosya arasında ara katman yok. Pages/Catalog.cshtml dosyası /Catalog adresinin kendisi:

@page
@model Ssr01.Pages.CatalogModel
<h1>Books (Razor Pages)</h1>
<ul>
    @foreach (BookRow book in Model.Books)
    {
        <li>@book.Title: @book.Price TL</li>
    }
</ul>
using Microsoft.AspNetCore.Mvc.RazorPages;
using Ssr01.Models;

namespace Ssr01.Pages;

public class CatalogModel : PageModel
{
    public BookRow[] Books { get; private set; } = [];

    public void OnGet() => Books = BookData.All;
}

İkisini aynı uygulamada çalıştırmak için Program.cs'te iki servis kaydı ve iki endpoint eşlemesi yeterli:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages();

var app = builder.Build();

app.UseRouting();
app.MapStaticAssets();

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}")
    .WithStaticAssets();
app.MapRazorPages()
    .WithStaticAssets();

app.Run();

curl http://localhost:17711/Books ve /Catalog yanıtlarının <main> kısmı, layout'tan gelen kısım çıkarılınca birebir aynı:

<h1>Books (MVC)</h1>
<ul>
        <li>The Pragmatic Path: 420 TL</li>
        <li>Rivers of Ink: 265.5 TL</li>
        <li>Clean Lines: 380 TL</li>
</ul>

Baştaki sorunun ilk yarısının cevabı bu: kitap adları ilk yanıtın içinde. Arama motoru botu, link önizlemesi üreten bir sohbet uygulaması ya da JavaScript'i kapalı bir tarayıcı, sayfanın içeriğini tek istekle görür.

Bir İsteğin Yaşam Döngüsü

01-request-flow-tr.png

İki modelin de akışı aynı iskeletten geçer: routing endpoint'i seçer, senin kodun (action ya da handler) modeli hazırlar, Razor view'ı render eder, layout onu sarar, ortaya tek bir HTML belgesi çıkar. Bunu tahmin etmek yerine framework'ün kendi log'larından görelim. appsettings.Development.json'da üç kategoriyi Information seviyesine açtım:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.AspNetCore.Routing.EndpointMiddleware": "Information",
      "Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker": "Information",
      "Microsoft.AspNetCore.Mvc.ViewFeatures": "Information",
      "Microsoft.AspNetCore.Mvc.RazorPages": "Information"
    }
  }
}

GET /Books isteğinin gerçek log'u:

info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'Ssr01.Controllers.BooksController.Index (Ssr01)'
info: Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker[102]
      Route matched with {action = "Index", controller = "Books", page = ""}. Executing controller action with signature Microsoft.AspNetCore.Mvc.IActionResult Index() on controller Ssr01.Controllers.BooksController (Ssr01).
info: Microsoft.AspNetCore.Mvc.ViewFeatures.ViewResultExecutor[1]
      Executing ViewResult, running view Index.
info: Microsoft.AspNetCore.Mvc.ViewFeatures.ViewResultExecutor[4]
      Executed ViewResult - view Index executed in 2.2863ms.
info: Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker[105]
      Executed action Ssr01.Controllers.BooksController.Index (Ssr01) in 2.5405ms
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint 'Ssr01.Controllers.BooksController.Index (Ssr01)'

Ve GET /Catalog:

info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint '/Catalog'
info: Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker[103]
      Route matched with {page = "/Catalog", action = "", controller = ""}. Executing page /Catalog
info: Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker[105]
      Executing handler method Ssr01.Pages.CatalogModel.OnGet - ModelState is Valid
info: Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker[108]
      Executed handler method OnGet, returned result .
info: Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker[107]
      Executing an implicit handler method - ModelState is Valid
info: Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker[109]
      Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
info: Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker[104]
      Executed page /Catalog in 7.0057ms

İki ayrıntı dikkat çekiyor. Birincisi route değerleri: MVC endpoint'inde page = "", Razor Pages endpoint'inde action = "" ve controller = "" var. İkisi aynı routing tablosunu paylaşıyor ve her endpoint diğer modelin değerlerini boş taşıyor; bu yüzden aynı projede çakışmadan yaşayabiliyorlar. İkincisi OnGet'in void dönmesi: returned result . satırı boş bir sonucu gösteriyor, ardından framework "implicit handler" ile PageResult üretiyor. Razor Pages'te bir handler hiçbir şey dönmezse sayfanın kendisi render edilir. MVC'de bu kısayol yok; action ne döndürecekse açıkça döndürür.

Süreler de fikir veriyor: uygulamanın aldığı ilk istekte (HomeController) view 59 ms'de render edildi; ısınma maliyeti, yani view assembly'sinin yüklenmesi ve kodun ilk JIT (Just-In-Time) derlemesi o isteğe denk geldi. Sonraki istekler 2-7 ms. Razor view'ları build sırasında C# class'larına derlendiği için çalışma anında şablon ayrıştırma maliyeti yok.

Sunucuda Render Etmenin Güçlü Olduğu Yerler

Şimdi baştaki sorunun ikinci yarısı. Sitenin UI Mimari Pattern'leri serisinde yazdığımız Angular Bookstore uygulamasının production build'indeki index.html dosyasının tamamı şu:

<!doctype html>
<html lang="en" data-beasties-container>
<head>
  <meta charset="utf-8">
  <title>Bookstore</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="stylesheet" href="styles-5INURTSO.css"></head>
<body>
  <app-root></app-root>
<link rel="modulepreload" href="chunk-CKQSIDE8.js"><script src="main-6MLYBZFF.js" type="module"></script></body>
</html>

474 byte ve içinde tek bir kitap adı yok; <app-root> boş. Kitap listesinin ekrana gelmesi için tarayıcı önce 135 KB'lık main-6MLYBZFF.js ile 128 KB'lık chunk-CKQSIDE8.js dosyalarını indirip çalıştırmalı, sonra uygulama Web API'ye ayrı bir istek atmalı, JSON (JavaScript Object Notation) yanıtı gelince de DOM'u (Document Object Model) çizmeli. MVC tarafında ise /Books yanıtı 2.502 byte ve listenin kendisi içinde. Sayfanın görünmesi için gereken tek ek iş CSS'in inmesi.

Bu fark SSR'ın güçlü olduğu yerleri belirler:

  • SEO (Search Engine Optimization) ve paylaşım önizlemeleri: içerik ilk yanıtta olduğu için JavaScript çalıştırmayan ya da sınırlı çalıştıran botlar sayfayı eksiksiz görür. Katalog, blog, ürün sayfası, dokümantasyon gibi içerik siteleri için bu tek başına yeterli sebep.
  • İlk yükleme: zayıf telefonda ve yavaş bağlantıda, yüzlerce KB JavaScript'i indirip ayrıştırmak yerine hazır HTML'i göstermek ilk ekranı belirgin şekilde öne çeker.
  • Form ağırlıklı iç uygulamalar: yönetim paneli, onay ekranı, rapor filtresi. Ekranların çoğu "listele, aç, düzenle, kaydet" döngüsü. Burada SPA'nın getirdiği client-side state yönetimi, ayrı bir build hattı ve API contract'ı çoğu zaman taşıdığından fazla yük.
  • Küçük ekip ve tek dil: aynı kişi veri erişimini, iş kuralını ve ekranı C# ile yazar. Ayrı bir frontend projesi, ayrı deploy ve iki tarafta tekrarlanan validation kuralları yok.
  • Güvenlik modeli: authentication çoğunlukla cookie ile, CSRF (Cross-Site Request Forgery) koruması form tag helper'ıyla hazır gelir. Token'ı tarayıcıda nerede saklayacağın sorusu hiç doğmaz.

SPA ve Web API'nin Güçlü Olduğu Yerler

Terazinin öbür kefesi de gerçek. Kullanıcı ekranda uzun süre kalıyor ve sürekli etkileşime giriyorsa (sürükle-bırak bir planlama tahtası, canlı güncellenen bir dashboard, Figma benzeri bir editör) her tıklamada sunucudan yeni HTML istemek hem yavaş hem kaba hissettirir. SPA'da bir kez inen uygulama sonraki her etkileşimi yerelde çözer ve sunucuyla yalnız veri konuşur.

İkinci güçlü taraf, aynı API'nin birden fazla istemciye hizmet etmesi. Mobil uygulaman, partner entegrasyonların ve web arayüzün aynı Web API'yi kullanıyorsa, web'i de o API'nin bir istemcisi yapmak tutarlı bir mimari verir. Üçüncüsü ekip yapısı: ayrı frontend ve backend ekipleri varsa, aralarındaki sınırı bir HTTP (HyperText Transfer Protocol) contract'ı olarak çizmek iki ekibin bağımsız deploy etmesini kolaylaştırır.

Ama bu avantajların bedeli var: iki dil, iki build hattı, iki tarafta tekrarlanan doğrulama, client-side routing, token yönetimi ve SEO için ek iş (Angular'da SSR ya da prerender kurmak gibi). Microsoft'un "Choose an ASP.NET Core UI" dokümanı da SPA'nın eksilerini aynı yerden sayıyor: daha fazla dil, framework ve araç; iki taraf arasında kod paylaşmanın zorluğu.

MVC mi Razor Pages mi?

Sunucuda render etmeye karar verdin. İkinci soru MVC mi, Razor Pages mi? İkisi aynı motoru kullandığı için fark teknik kabiliyette değil, kodun nasıl organize olduğunda:

KonuMVCRazor Pages
Organizasyon ekseniController merkezli: bir controller birçok action ve view'ı toplarSayfa merkezli: her sayfa kendi .cshtml ve PageModel çiftidir
URLRoute template'ten ({controller}/{action}/{id?})Klasör ve dosya yolundan (Pages/Admin/Books/Edit.cshtml)
GET ve POSTAynı ya da farklı controller'da ayrı action'larAynı PageModel'de OnGet ve OnPost
Bir view'ı birden fazla action'dan kullanmakDoğal (View("Edit", model))Mümkün ama alışılmış değil
Templatedotnet new mvcdotnet new webapp
Uygun olduğu yerAynı kaynağın birçok gösterimi, API ile ortak controller kültürü, legacy MVC 5 kodundan geçişEkran başına bağımsız iş akışı, CRUD (Create, Read, Update, Delete) ağırlıklı paneller, form sayfaları

Pratikte şöyle düşünüyorum: ekranlarının çoğu birbirinden bağımsız formlarsa Razor Pages kodu daha az dosyaya, daha az yönlendirmeye dağıtır. BooksController zamanla 15 action'lık, 6 farklı service enjekte edilen bir dosyaya dönüşür; Razor Pages'te aynı iş 5 küçük PageModel'e bölünür ve her biri yalnız ihtiyacı olan bağımlılığı alır. Öte yandan aynı veriyi farklı biçimlerde sunan, filter'ları ve route kurallarını controller seviyesinde paylaşan, ekibi zaten Web API controller'larına alışkın bir uygulamada MVC daha doğal.

Ve en önemlisi: seçim kesin değil. Yukarıdaki Ssr01 projesi ikisini aynı anda çalıştırıyor. Halka açık katalog MVC ile, yönetim paneli Razor Pages ile yazılmış bir uygulama tamamen meşru; bu seride de tam olarak böyle ilerleyeceğiz.

Blazor SSR Nerede Duruyor?

Resmin üçüncü parçası Blazor. .NET 8 ile gelen Blazor Web App template'i, Razor component'leri statik olarak sunucuda render edebiliyor (static SSR), istenen component'i de interaktif hâle getirebiliyor. Microsoft'un "Choose an ASP.NET Core UI" sayfası açıkça şunu söylüyor: "For new project development, we recommend ASP.NET Core Blazor." Yani yeni bir projeye sıfırdan başlıyorsan Microsoft'un önerdiği varsayılan Blazor.

O hâlde neden hâlâ MVC ve Razor Pages? Birkaç somut sebep var. Birincisi kurulu taban: kurumsal dünyada çalışan çok sayıda uygulama ASP.NET MVC 5 ya da ASP.NET Core MVC ile yazılmış; bu kodu okuyacak, bakımını yapacak ve taşıyacak olan sensin. İkincisi model basitliği: MVC ve Razor Pages'te bir istek bir yanıt üretir, bağlantı açık kalmaz, component yaşam döngüsü, render mode ve prerender sırasında state kaybı gibi kavramlar yok. Üçüncüsü olgunluk: htmx gibi hafif kütüphanelerle MVC ve Razor Pages üzerine SPA'sız etkileşim eklemek çok yaygın ve iyi belgelenmiş bir yol. Dördüncüsü, ikisi Blazor'la rakip değil: aynı dokümanın son bölümü MVC veya Razor Pages uygulamasına Razor component eklemeyi ayrı bir seçenek olarak anlatıyor. Bu serinin 4. yazısında bir Razor component'i MVC view'ının içinde render edeceğiz. Blazor'un kendisi ilerideki Blazor serisinde.

Üretimde Dikkat

SSR sunucuya iş yükler: her sayfa görüntülemesi bir action, muhtemelen bir veritabanı sorgusu ve bir view render'ı demek. Yukarıdaki log'larda view'ın 2-7 ms'de render edildiğini gördük; bu sürenin çoğu zaman en ucuz kısım olduğunu unutma, asıl maliyet veri erişiminde. Bu yüzden SSR uygulamasında ölçmen gereken ilk şey action başına sorgu sayısı ve süresi. Entity Framework Core (EF Core) serisindeki sorgu loglama ve N+1 konuları burada birebir geçerli.

İkinci konu cache. Herkese aynı görünen katalog sayfası gibi içerik, output caching ya da bir CDN (Content Delivery Network) ile sunucuya hiç uğramadan dönebilir. Kullanıcıya özel sayfalarda ise bu mümkün değil; caching'in ayrıntıları ilerideki Caching serisinde. Üçüncüsü, sunucuda render edilen sayfada her etkileşim tam sayfa yenilemesi anlamına gelir. Panelde tablo filtrelemek için sayfanın tamamını yeniden çizmek kullanıcıya yavaş geliyorsa, çözüm illa SPA değil; bu serinin 11. yazısında htmx ile sayfanın yalnız bir parçasını güncelleyeceğiz.

Son olarak ölçek: MVC ve Razor Pages stateless'tır, her istek bağımsızdır. Yük dengeleyicinin arkasına yeni instance eklemek yeterli. Tek dikkat noktası, antiforgery ve authentication cookie'lerini şifreleyen Data Protection key'lerinin bütün instance'lar arasında paylaşılması; aksi hâlde bir instance'ın ürettiği form token'ını diğeri doğrulayamaz.

Sık Düşülen Tuzaklar

"SEO lazım" diye her şeyi SSR yapmak, ya da "modern olsun" diye her şeyi SPA yapmak. Seçim ekran ekran yapılabilir. İçerik sayfaları sunucuda, yoğun etkileşimli tek bir editör ekranı ayrı bir SPA ya da interaktif bir Blazor component olarak yaşayabilir.

MVC ile Razor Pages'i farklı framework'ler sanmak. Aynı projede, aynı layout'u, aynı tag helper'ları, aynı DI (Dependency Injection) container'ını paylaşırlar. Birinden diğerine geçmek için uygulamayı baştan yazmak gerekmez; ekran ekran taşıyabilirsin.

SSR'ı "JavaScript yok" sanmak. Sunucuda render edilen bir sayfa da client-side validation, dropdown ya da modal için JavaScript kullanır; template'te jQuery ve Bootstrap'in hazır gelmesi bu yüzden. Fark, sayfanın anlamlı olması için JavaScript'e muhtaç olmaması.

Blazor tavsiyesini "MVC öldü" diye okumak. Doküman yeni projeler için bir öneri yapıyor; MVC ve Razor Pages desteklenmeye ve her .NET sürümünde güncellenmeye devam ediyor. Bu serinin 13. yazısı .NET 8'den .NET 11'e gelen değişiklikleri topluyor.

Küçük Bir Deneme

★☆☆ dotnet new webapp ve dotnet new mvc ile iki proje oluştur. Klasör yapılarını karşılaştır: hangi dosyalar ortak, hangileri yalnızca birinde var?

★★☆ Yukarıdaki Ssr01 projesini kur ve log seviyelerini aynı şekilde aç. CatalogModel.OnGet'i public IActionResult OnGet() yapıp return Page(); döndür. Log'da "implicit handler" satırları kayboluyor mu?

★★★ Aynı projeye Pages/Books/Index.cshtml adında bir Razor Page ekle. Artık /Books adresini hem BooksController.Index hem bu sayfa istiyor. Hata mı alıyorsun, yoksa biri sessizce mi kazanıyor? Log'daki Executing endpoint satırına bak, sonra EndpointDataSource'u enjekte eden küçük bir endpoint yazıp her RouteEndpoint'in Order değerini listele. MapControllerRoute'un endpoint'lerine verdiği sıra değeri sonucu açıklıyor mu?

Karar verdik: Bookstore'un katalog ve yönetim paneli sunucuda render edilecek. Bir sonraki yazıda dotnet new mvc ile projeyi kuruyor, conventional routing'i, Area'ları, view bulma kurallarını ve ViewData yerine neden ViewModel kullanmamız gerektiğini gerçek bir controller üzerinde görüyoruz.