.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 anEndpoints/folder, rather than defining all routes inProgram.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 PageOnPosthandler that processes form submissions, and addapp.UseAntiforgery()to the middleware pipeline, to prevent cross-site request forgery attacks on state-changing operations. -
Use
TempDatafor post-redirect-get (PRG) success messages in MVC and Razor Pages instead of passing messages via query strings or storing them in session, becauseTempDatais 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 nestedInputclass 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()orAddRazorPages()withAddJsonOptions(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
IAsyncActionFilteror endpoint filters for cross-cutting validation rather than repeatingModelState.IsValidchecks 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
ForwardedHeadersmiddleware to preserve the original client IP, scheme, and host from theX-Forwarded-*headers, because without this configuration,HttpContext.Connection.RemoteIpAddressreturns the proxy's IP andRequest.Schemereturnshttpinstead ofhttps.
