ASP.NET MVC Tutorial: Learn Web Apps from Scratch (2026)
I built my first ASP.NET MVC application in 2010 during the MVC 2 era, and it felt like coming up for air. The WebForms model had dominated .NET web development for years with its page lifecycle, view state, and server controls that pretended the web was a Windows form. MVC stripped all that away and gave us back the HTTP — requests, responses, routes, and clean separation of concerns. It was liberating.
ASP.NET MVC is a mature web application framework that implements the Model-View-Controller pattern. It gives you full control over HTML, supports test-driven development, and integrates seamlessly with the broader .NET ecosystem. Even though ASP.NET Core has superseded it, millions of production applications still run on ASP.NET MVC, and understanding its architecture is essential for maintaining and migrating those systems. This tutorial covers the framework's core concepts and best practices.
The MVC Pattern: Controllers, Views, and Models
The MVC pattern divides your application into three interconnected components. Controllers handle incoming HTTP requests, execute business logic, and return responses. Views render the user interface using the data provided by controllers. Models represent the domain data and business rules. This separation makes the application easier to test, maintain, and extend compared to the monolithic WebForms code-behind model.
A controller action receives parameters from the request URL, query string, or form data via model binding. It processes the data, interacts with services or repositories, and returns an ActionResult — typically a ViewResult that renders a Razor template. The framework's routing engine maps incoming URLs to controller actions based on configurable route templates, enabling clean, SEO-friendly URLs without file extensions.
public class ProductsController : Controller
{
private readonly IProductRepository _repo;
public ProductsController(IProductRepository repo)
{
_repo = repo;
}
public ActionResult Index()
{
var products = _repo.GetAll();
return View(products);
}
public ActionResult Details(int id)
{
var product = _repo.GetById(id);
if (product == null) return HttpNotFound();
return View(product);
}
}
Routing and URL Conventions
ASP.NET MVC routing maps URLs to controller actions. The default route pattern {controller}/{action}/{id} means a URL like /Products/Details/5 invokes the Details method on the ProductsController with id=5. Routes are defined in RouteConfig.cs and are matched in order — the first match wins. You can define custom routes for specific patterns, constrain parameters with regex, and provide default values.
Attribute routing, introduced in MVC 5, decorates actions and controllers directly with route templates. This keeps route definitions close to the code they execute and supports complex patterns like route prefixes and optional parameters. I prefer attribute routing for everything except the most trivial applications because it localizes routing knowledge and makes the URL structure immediately visible in the controller code.
routes.MapRoute(
name: "Default",
url: "{controller}/{action}/{id}",
defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
);
[RoutePrefix("products")]
public class ProductsController : Controller
{
[Route("{id:int:min(1)}")]
public ActionResult Details(int id) => View();
}
Razor Views and Layouts
Razor is the view engine for ASP.NET MVC, offering a clean syntax for embedding C# code in HTML. The @ character transitions from HTML to C#, enabling loops, conditionals, and data rendering without the spaghetti code of classic ASP. Razor views automatically HTML-encode output, preventing XSS vulnerabilities. Layout files define the common page structure — header, navigation, footer — and child views fill in content sections.
Partial views render reusable UI fragments. @Html.Partial and @Html.RenderAction inject common controls like login panels or sidebar widgets. ViewBag and ViewData pass data from controllers to views, though strongly-typed view models are preferred for compile-time safety. For complex UI logic, display templates and editor templates automatically render properties based on their type or a custom template name.
@model IEnumerable
@Html.DisplayNameFor(m => m.Name)
@Html.DisplayNameFor(m => m.Price)
@foreach (var product in Model)
{
@Html.ActionLink(product.Name, "Details", new { id = product.Id })
@product.Price.ToString("C")
}
Model Binding and Validation
Model binding automatically maps HTTP request data — form values, query string parameters, route data, and JSON body — to action method parameters or complex objects. The DefaultModelBinder handles primitive types, collections, and nested objects. A POST to /Products/Create with form fields matching Product properties populates a Product parameter automatically. Custom model binders handle non-standard formats or legacy data.
Validation in ASP.NET MVC uses data annotations on model properties. Attributes like [Required], [StringLength], [Range], and [RegularExpression] define validation rules declaratively. Server-side validation runs automatically during model binding, and validation errors are added to ModelState. The Html.ValidationMessageFor helper displays errors in views. Client-side validation using jQuery Unobtrusive Validation mirrors server rules, providing instant feedback without a round trip.
public class Product
{
public int Id { get; set; }
[Required, StringLength(100)]
public string Name { get; set; }
[Range(0.01, 10000)]
public decimal Price { get; set; }
[Required]
public string Category { get; set; }
}
[HttpPost]
public ActionResult Create(Product product)
{
if (!ModelState.IsValid)
return View(product);
_repo.Add(product);
return RedirectToAction("Index");
}
Filters and Action Lifecycle
Filters are attributes that execute code before or after controller actions, providing cross-cutting concerns. Authorization filters run first and determine whether the user is allowed to access the action. Action filters wrap the action execution, useful for logging or transaction management. Result filters wrap the execution of the action result. Exception filters handle unhandled exceptions from actions, replacing the default yellow error screen with custom error pages.
The OnActionExecuting and OnActionExecuted hooks let you inspect and modify parameters and results. A custom action filter might log execution time, audit access, or set ViewBag data globally. Filters can be applied at the action, controller, or global level. The order of execution is well-defined: Authorization, Action, Result, Exception — with global filters running before controller filters, which run before action filters.
public class LogActionFilter : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
var controller = filterContext.RouteData.Values["controller"];
var action = filterContext.RouteData.Values["action"];
Debug.WriteLine($"Executing {controller}/{action}");
}
public override void OnActionExecuted(ActionExecutedContext filterContext)
{
Debug.WriteLine($"Completed with status {filterContext.HttpContext.Response.StatusCode}");
}
}
Areas and Bundling for Large Applications
When applications grow large, Areas provide a way to partition functionality into distinct sections — Admin, Customer, Billing — each with its own Controllers, Views, and Models folders. Each area registers its own routes via AreaRegistration. Areas help large teams work independently on different parts of the application without merge conflicts, and they keep the project structure navigable.
Bundling and minification (BundleConfig) combine CSS and JavaScript files into fewer HTTP requests and reduce file sizes. The framework handles cache busting through bundle versioning. ScriptBundle and StyleBundle classes define the bundles, and @Styles.Render and @Scripts.Render render the appropriate HTML tags. In debug mode, bundles render individual files for easier debugging; in release mode, they render combined, minified files.
public class AdminAreaRegistration : AreaRegistration
{
public override string AreaName => "Admin";
public override void RegisterArea(AreaRegistrationContext context)
{
context.MapRoute(
"Admin_default",
"Admin/{controller}/{action}/{id}",
new { controller = "Dashboard", action = "Index", id = UrlParameter.Optional }
);
}
}
public class BundleConfig
{
public static void RegisterBundles(BundleCollection bundles)
{
bundles.Add(new ScriptBundle("~/bundles/jquery").Include("~/Scripts/jquery-{version}.js"));
bundles.Add(new StyleBundle("~/Content/css").Include("~/Content/site.css"));
}
}
Frequently Asked Questions
What is the difference between ASP.NET MVC and ASP.NET Core?
ASP.NET MVC runs on .NET Framework and Windows only. ASP.NET Core is the cross-platform, open-source rewrite that includes MVC functionality plus Razor Pages, Blazor, and Minimal APIs. New development should use ASP.NET Core.
How do I handle authentication in ASP.NET MVC?
ASP.NET MVC supports Forms Authentication, Windows Authentication, and OAuth/OpenID Connect via Identity. The [Authorize] attribute protects controllers and actions. Use Identity for user registration, password management, and role-based access.
What is the role of the Global.asax file?
Global.asax handles application-level events — Application_Start, Application_Error, Session_Start, etc. It registers routes, filters, and bundles during startup, and handles unhandled exceptions globally.
Can ASP.NET MVC and WebForms coexist?
Yes, they can run side-by-side in the same application. Configure routes to exclude WebForms paths, and both frameworks will process requests independently. This is useful during gradual migration.
Originally published on Ayodhyyya. Last updated June 1, 2026.