.NET Web Apps
Overview
ASP.NET Core provides multiple models for building web applications: MVC (Model-View-Controller) for complex server-rendered apps, Razor Pages for page-focused scenarios, Minimal APIs for lightweight HTTP services, and Blazor for interactive web UIs with C#. Each model runs on the same ASP.NET Core pipeline and shares the dependency injection, configuration, middleware, and authentication infrastructure. Choosing the right model depends on the application's complexity, team familiarity, and whether the UI is server-rendered, client-rendered, or API-driven.
MVC Pattern
Use the MVC pattern for applications with complex routing, multiple views per controller, and shared layouts.
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddScoped<IProductService, ProductService>();
var app = builder.Build();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}");
app.Run();
// Controllers/ProductsController.cs
using Microsoft.AspNetCore.Mvc;
namespace MyApp.Controllers;
public class ProductsController : Controller
{
private readonly IProductService _productService;
public ProductsController(IProductService productService)
{
_productService = productService;
}
public async Task<IActionResult> Index(string? category, int page = 1)
{
var products = await _productService.GetPagedAsync(category, page, pageSize: 20);
ViewBag.CurrentCategory = category;
return View(products);
}
public async Task<IActionResult> Details(int id)
{
var product = await _productService.GetByIdAsync(id);
if (product is null) return NotFound();
return View(product);
}
[HttpGet]
public IActionResult Create() => View();
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateProductViewModel model)
{
if (!ModelState.IsValid) return View(model);
await _productService.CreateAsync(model);
TempData["Success"] = "Product created successfully.";
return RedirectToAction(nameof(Index));
}
}
Razor Pages
Use Razor Pages for page-centric applications where each URL maps to a single page with its own model.
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
builder.Services.AddScoped<IContactService, ContactService>();
var app = builder.Build();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapRazorPages();
app.Run();
// Pages/Contacts/Create.cshtml.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.ComponentModel.DataAnnotations;
namespace MyApp.Pages.Contacts;
public class CreateModel : PageModel
{
private readonly IContactService _contactService;
public CreateModel(IContactService contactService)
{
_contactService = contactService;
}
[BindProperty]
public ContactInput Input { get; set; } = new();
public void OnGet() { }
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid) return Page();
await _contactService.CreateAsync(new Contact
{
Name = Input.Name,
Email = Input.Email,
Message = Input.Message
});
TempData["Success"] = "Contact submitted.";
return RedirectToPage("/Contacts/Index");
}
public class ContactInput
{
[Required, StringLength(100)]
public string Name { get; set; } = string.Empty;
[Required, EmailAddress]
public string Email { get; set; } = string.Empty;
[Required, StringLength(2000)]
public string Message { get; set; } = string.Empty;
}
}
Minimal APIs with Endpoint Groups
Use minimal APIs for lightweight microservices and API-only projects.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddScoped<IOrderService, OrderService>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.MapGroup("/api/orders")
.WithTags("Orders")
.MapOrderEndpoints();
app.Run();
// Extensions/OrderEndpoints.cs
public static class OrderEndpoints
{
public static RouteGroupBuilder MapOrderEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/", async (IOrderService service, int page = 1) =>
Results.Ok(await service.GetPagedAsync(page)));
group.MapGet("/{id:int}", async (int id, IOrderService service) =>
{
var order = await service.GetByIdAsync(id);
return order is not null ? Results.Ok(order) : Results.NotFound();
});
group.MapPost("/", async (CreateOrderDto dto, IOrderService service) =>
{
var order = await service.CreateAsync(dto);
return Results.Created($"/api/orders/{order.Id}", order);
});
return group;
}
}
Static Server-Side Rendering with Blazor
Use Blazor SSR for server-rendered pages with optional interactive islands.
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
var app = builder.Build();
app.UseStaticFiles();
app.UseAntiforgery();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();
Web Application Model Comparison
| Feature |
MVC |
Razor Pages |
Minimal APIs |
Blazor SSR |
| Best for |
Complex web apps |
Page-centric apps |
Microservices/APIs |
Interactive web UI |
| Routing |
Convention + attribute |
File/folder-based |
Lambda-based |
Component-based |
| Views |
Razor views (.cshtml) |
Razor pages (.cshtml) |
JSON responses |
Razor components (.razor) |
| Model binding |
[FromForm], [FromBody] |
[BindProperty] |
Parameter injection |
@bind, EditForm |
| Testability |
Controller unit tests |
PageModel unit tests |
Endpoint delegate tests |
Component tests |
| SEO |
Server-rendered HTML |
Server-rendered HTML |
N/A (API) |
Server-rendered HTML |
| Complexity |
Higher |
Moderate |
Lowest |
Moderate |
| Areas/Sections |
Yes (Areas) |
Yes (folders) |
Groups |
Layouts |
Best Practices
Choose Razor Pages for page-centric web apps where each URL corresponds to a single page (e.g., contact forms, dashboards, admin panels), and MVC only when multiple actions per controller are genuinely needed (e.g., a products controller with CRUD + search + bulk operations sharing the same service dependencies).
Organize minimal API endpoints into static extension methods (e.g., MapOrderEndpoints(), MapUserEndpoints()) in separate files under an Endpoints/ folder, rather than defining all routes in Program.cs, to keep the startup file under 50 lines and make each endpoint group independently navigable.
Use MapGroup() to share route prefixes, tags, filters, and authorization policies across related endpoints instead of duplicating .RequireAuthorization() and .WithTags() on every individual endpoint, reducing boilerplate and ensuring policy consistency when new endpoints are added.
Apply [ValidateAntiForgeryToken] on every MVC [HttpPost] action and Razor Page OnPost handler that processes form submissions, and add app.UseAntiforgery() to the middleware pipeline, to prevent cross-site request forgery attacks on state-changing operations.
Use TempData for post-redirect-get (PRG) success messages in MVC and Razor Pages instead of passing messages via query strings or storing them in session, because TempData is automatically cleared after the next request and does not persist across browser refreshes.
Set [BindProperty] on Razor Page properties that receive form data and use a nested Input class to group all bound properties, rather than binding directly to the domain model, to prevent over-posting attacks where malicious users submit fields that should not be user-editable.
Configure AddControllersWithViews() or AddRazorPages() with AddJsonOptions(options => options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase) to ensure JSON responses use camelCase property names, matching JavaScript conventions and preventing front-end mapping errors.
Use the IWebHostEnvironment.IsDevelopment() check to conditionally enable Swagger, detailed error pages, and developer exception page so that sensitive diagnostic information is never exposed in production; app.UseDeveloperExceptionPage() leaks stack traces and connection strings.
Implement IAsyncActionFilter or endpoint filters for cross-cutting validation rather than repeating ModelState.IsValid checks in every controller action, centralizing validation logic and ensuring no action accidentally skips the check.
Deploy behind a reverse proxy (NGINX, Azure App Gateway, YARP) and configure ForwardedHeaders middleware to preserve the original client IP, scheme, and host from the X-Forwarded-* headers, because without this configuration, HttpContext.Connection.RemoteIpAddress returns the proxy's IP and Request.Scheme returns http instead of https.
1---2name: dotnet-web-apps3description: |4 USE FOR: Choosing between and implementing .NET web application patterns including MVC, Razor Pages, Minimal APIs, and Blazor. Use when deciding on architecture, project structure, and routing strategies for ASP.NET Core web applications.5 DO NOT USE FOR: Native mobile/desktop apps (use MAUI or Avalonia), game development (use Unity or MonoGame), or projects that exclusively need a REST API without any server-rendered content (use the aspnet-core skill directly).6license: MIT7metadata:8 displayName: .NET Web Apps9 author: "Tyler-R-Kendrick"10 version: "1.0.0"11compatibility:12 - claude13 - copilot14 - cursor15references:16 - title: "ASP.NET Core Documentation"17 url: "https://learn.microsoft.com/en-us/aspnet/core/"18 - title: "Common Web Application Architectures"19 url: "https://learn.microsoft.com/en-us/dotnet/architecture/modern-web-apps-azure/common-web-application-architectures"20 - title: "ASP.NET Core GitHub Repository"21 url: "https://github.com/dotnet/aspnetcore"22---2324# .NET Web Apps2526## Overview2728ASP.NET Core provides multiple models for building web applications: MVC (Model-View-Controller) for complex server-rendered apps, Razor Pages for page-focused scenarios, Minimal APIs for lightweight HTTP services, and Blazor for interactive web UIs with C#. Each model runs on the same ASP.NET Core pipeline and shares the dependency injection, configuration, middleware, and authentication infrastructure. Choosing the right model depends on the application's complexity, team familiarity, and whether the UI is server-rendered, client-rendered, or API-driven.2930## MVC Pattern3132Use the MVC pattern for applications with complex routing, multiple views per controller, and shared layouts.3334```csharp35// Program.cs36var builder = WebApplication.CreateBuilder(args);37builder.Services.AddControllersWithViews();38builder.Services.AddScoped<IProductService, ProductService>();3940var app = builder.Build();41app.UseStaticFiles();42app.UseRouting();43app.UseAuthorization();44app.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}");45app.Run();4647// Controllers/ProductsController.cs48using Microsoft.AspNetCore.Mvc;4950namespace MyApp.Controllers;5152public class ProductsController : Controller53{54 private readonly IProductService _productService;5556 public ProductsController(IProductService productService)57 {58 _productService = productService;59 }6061 public async Task<IActionResult> Index(string? category, int page = 1)62 {63 var products = await _productService.GetPagedAsync(category, page, pageSize: 20);64 ViewBag.CurrentCategory = category;65 return View(products);66 }6768 public async Task<IActionResult> Details(int id)69 {70 var product = await _productService.GetByIdAsync(id);71 if (product is null) return NotFound();72 return View(product);73 }7475 [HttpGet]76 public IActionResult Create() => View();7778 [HttpPost]79 [ValidateAntiForgeryToken]80 public async Task<IActionResult> Create(CreateProductViewModel model)81 {82 if (!ModelState.IsValid) return View(model);8384 await _productService.CreateAsync(model);85 TempData["Success"] = "Product created successfully.";86 return RedirectToAction(nameof(Index));87 }88}89```9091## Razor Pages9293Use Razor Pages for page-centric applications where each URL maps to a single page with its own model.9495```csharp96// Program.cs97var builder = WebApplication.CreateBuilder(args);98builder.Services.AddRazorPages();99builder.Services.AddScoped<IContactService, ContactService>();100101var app = builder.Build();102app.UseStaticFiles();103app.UseRouting();104app.UseAuthorization();105app.MapRazorPages();106app.Run();107108// Pages/Contacts/Create.cshtml.cs109using Microsoft.AspNetCore.Mvc;110using Microsoft.AspNetCore.Mvc.RazorPages;111using System.ComponentModel.DataAnnotations;112113namespace MyApp.Pages.Contacts;114115public class CreateModel : PageModel116{117 private readonly IContactService _contactService;118119 public CreateModel(IContactService contactService)120 {121 _contactService = contactService;122 }123124 [BindProperty]125 public ContactInput Input { get; set; } = new();126127 public void OnGet() { }128129 public async Task<IActionResult> OnPostAsync()130 {131 if (!ModelState.IsValid) return Page();132133 await _contactService.CreateAsync(new Contact134 {135 Name = Input.Name,136 Email = Input.Email,137 Message = Input.Message138 });139140 TempData["Success"] = "Contact submitted.";141 return RedirectToPage("/Contacts/Index");142 }143144 public class ContactInput145 {146 [Required, StringLength(100)]147 public string Name { get; set; } = string.Empty;148149 [Required, EmailAddress]150 public string Email { get; set; } = string.Empty;151152 [Required, StringLength(2000)]153 public string Message { get; set; } = string.Empty;154 }155}156```157158## Minimal APIs with Endpoint Groups159160Use minimal APIs for lightweight microservices and API-only projects.161162```csharp163var builder = WebApplication.CreateBuilder(args);164builder.Services.AddEndpointsApiExplorer();165builder.Services.AddSwaggerGen();166builder.Services.AddScoped<IOrderService, OrderService>();167168var app = builder.Build();169170if (app.Environment.IsDevelopment())171{172 app.UseSwagger();173 app.UseSwaggerUI();174}175176app.MapGroup("/api/orders")177 .WithTags("Orders")178 .MapOrderEndpoints();179180app.Run();181182// Extensions/OrderEndpoints.cs183public static class OrderEndpoints184{185 public static RouteGroupBuilder MapOrderEndpoints(this RouteGroupBuilder group)186 {187 group.MapGet("/", async (IOrderService service, int page = 1) =>188 Results.Ok(await service.GetPagedAsync(page)));189190 group.MapGet("/{id:int}", async (int id, IOrderService service) =>191 {192 var order = await service.GetByIdAsync(id);193 return order is not null ? Results.Ok(order) : Results.NotFound();194 });195196 group.MapPost("/", async (CreateOrderDto dto, IOrderService service) =>197 {198 var order = await service.CreateAsync(dto);199 return Results.Created($"/api/orders/{order.Id}", order);200 });201202 return group;203 }204}205```206207## Static Server-Side Rendering with Blazor208209Use Blazor SSR for server-rendered pages with optional interactive islands.210211```csharp212// Program.cs213var builder = WebApplication.CreateBuilder(args);214builder.Services.AddRazorComponents()215 .AddInteractiveServerComponents();216217var app = builder.Build();218app.UseStaticFiles();219app.UseAntiforgery();220app.MapRazorComponents<App>()221 .AddInteractiveServerRenderMode();222app.Run();223```224225## Web Application Model Comparison226227| Feature | MVC | Razor Pages | Minimal APIs | Blazor SSR |228|---|---|---|---|---|229| Best for | Complex web apps | Page-centric apps | Microservices/APIs | Interactive web UI |230| Routing | Convention + attribute | File/folder-based | Lambda-based | Component-based |231| Views | Razor views (.cshtml) | Razor pages (.cshtml) | JSON responses | Razor components (.razor) |232| Model binding | `[FromForm]`, `[FromBody]` | `[BindProperty]` | Parameter injection | `@bind`, `EditForm` |233| Testability | Controller unit tests | PageModel unit tests | Endpoint delegate tests | Component tests |234| SEO | Server-rendered HTML | Server-rendered HTML | N/A (API) | Server-rendered HTML |235| Complexity | Higher | Moderate | Lowest | Moderate |236| Areas/Sections | Yes (Areas) | Yes (folders) | Groups | Layouts |237238## Best Practices2392401. **Choose Razor Pages for page-centric web apps** where each URL corresponds to a single page (e.g., contact forms, dashboards, admin panels), and MVC only when multiple actions per controller are genuinely needed (e.g., a products controller with CRUD + search + bulk operations sharing the same service dependencies).2412422. **Organize minimal API endpoints into static extension methods** (e.g., `MapOrderEndpoints()`, `MapUserEndpoints()`) in separate files under an `Endpoints/` folder, rather than defining all routes in `Program.cs`, to keep the startup file under 50 lines and make each endpoint group independently navigable.2432443. **Use `MapGroup()` to share route prefixes, tags, filters, and authorization policies** across related endpoints instead of duplicating `.RequireAuthorization()` and `.WithTags()` on every individual endpoint, reducing boilerplate and ensuring policy consistency when new endpoints are added.2452464. **Apply `[ValidateAntiForgeryToken]` on every MVC `[HttpPost]` action and Razor Page `OnPost` handler** that processes form submissions, and add `app.UseAntiforgery()` to the middleware pipeline, to prevent cross-site request forgery attacks on state-changing operations.2472485. **Use `TempData` for post-redirect-get (PRG) success messages** in MVC and Razor Pages instead of passing messages via query strings or storing them in session, because `TempData` is automatically cleared after the next request and does not persist across browser refreshes.2492506. **Set `[BindProperty]` on Razor Page properties that receive form data** and use a nested `Input` class to group all bound properties, rather than binding directly to the domain model, to prevent over-posting attacks where malicious users submit fields that should not be user-editable.2512527. **Configure `AddControllersWithViews()` or `AddRazorPages()` with `AddJsonOptions(options => options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase)`** to ensure JSON responses use camelCase property names, matching JavaScript conventions and preventing front-end mapping errors.2532548. **Use the `IWebHostEnvironment.IsDevelopment()` check to conditionally enable Swagger, detailed error pages, and developer exception page** so that sensitive diagnostic information is never exposed in production; `app.UseDeveloperExceptionPage()` leaks stack traces and connection strings.2552569. **Implement `IAsyncActionFilter` or endpoint filters for cross-cutting validation** rather than repeating `ModelState.IsValid` checks in every controller action, centralizing validation logic and ensuring no action accidentally skips the check.25725810. **Deploy behind a reverse proxy (NGINX, Azure App Gateway, YARP) and configure `ForwardedHeaders` middleware** to preserve the original client IP, scheme, and host from the `X-Forwarded-*` headers, because without this configuration, `HttpContext.Connection.RemoteIpAddress` returns the proxy's IP and `Request.Scheme` returns `http` instead of `https`.