Skip to content

MVC & Razor Pages

Key Points

  • MVC organizes around Controller classes with action methods, view templates, and routes by attribute or convention.
  • Razor Pages organizes by page (one PageModel + Razor view per URL). Often clearer for traditional CRUD UI.
  • For JSON APIs in 2026: prefer Minimal APIs. For server-rendered HTML: choose Razor Pages over MVC for UI-centric apps; choose MVC for complex action conventions or existing MVC codebases.
  • Conventions matter: [ApiController] opts in to model validation, ProblemDetails on errors, automatic 400 on invalid model, etc.
  • Razor Pages handlers: OnGet, OnPost, OnPostHandler for named handlers (?handler=Foo).

Concepts (deep dive)

MVC controller

[ApiController]
[Route("api/[controller]")]
public class OrdersController(IRepo repo) : ControllerBase
{
    [HttpGet("{id:int}")]
    public async Task<ActionResult<Order>> Get(int id)
    {
        var o = await repo.GetAsync(id);
        return o is not null ? Ok(o) : NotFound();
    }

    [HttpPost]
    public async Task<ActionResult<Order>> Create([FromBody] CreateOrderRequest req)
    {
        var saved = await repo.AddAsync(req);
        return CreatedAtAction(nameof(Get), new { id = saved.Id }, saved);
    }
}

[ApiController] enables: - Automatic model state validation → 400 with ProblemDetails on invalid input. - Inferred parameter binding (complex from body, simple from route/query). - ProblemDetails for error status codes. - Multipart/form-data binding.

Razor Pages

Pages/
├── Orders/
│   ├── Index.cshtml          (the view)
│   └── Index.cshtml.cs       (the PageModel)
public class IndexModel(IRepo repo) : PageModel
{
    public List<Order> Orders { get; private set; } = new();

    public async Task OnGetAsync()
    {
        Orders = await repo.GetAllAsync();
    }

    public async Task<IActionResult> OnPostDeleteAsync(int id)
    {
        await repo.DeleteAsync(id);
        return RedirectToPage();
    }
}
@page "{id:int?}"
@model IndexModel
<h1>Orders</h1>
<table>
@foreach (var o in Model.Orders) { ... }
</table>

@page "{id:int?}" declares the route. OnGet, OnGetAsync, OnPost, OnPostHandlerName handle requests.

MVC vs Razor Pages — when to choose

Aspect MVC Razor Pages
Organization By controller (collection of actions) By page (single URL = single file pair)
Best for API + complex flows UI/CRUD apps
Routing Attribute or convention File-based (@page)
Filters Same [Filter] attributes work Same
Razor views Yes Yes (the view is the page)
Controllers Required Not used (PageModel replaces)

For JSON APIs, neither — use Minimal APIs. For traditional server-rendered apps, Razor Pages is usually cleaner.

Routing for MVC

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

// Attribute routing (preferred for APIs)
[Route("api/v1/[controller]")]
public class OrdersController { }

[Route] with [controller] token auto-uses the type name minus "Controller". Combine with [HttpGet("{id}")], etc.

Action results

return Ok(value);                   // 200 with body
return Created(uri, value);          // 201 with Location header
return NoContent();                  // 204
return NotFound();                   // 404
return BadRequest("error");          // 400 with body
return Forbid();                     // 403
return Problem(detail: "...");       // 500 ProblemDetails
return File(stream, "image/png");    // file download
return RedirectToAction("Other");    // 302
return View(model);                  // Razor view (MVC controllers)
return Page();                       // Razor Pages

ActionResult<T> is the typed wrapper — lets you return either a T or any IActionResult.

[ApiController] automatic model validation

Without [ApiController]:

[HttpPost]
public IActionResult Create(Order order)
{
    if (!ModelState.IsValid) return BadRequest(ModelState);
    // ...
}

With [ApiController], the framework auto-returns 400 + ProblemDetails when ModelState.IsValid is false — no boilerplate.

Conventions and [Bind]

public IActionResult Create([Bind("Name,Email")] Customer c) { ... }

[Bind] whitelists which properties bind from input — defends against over-posting (where attacker sends extra fields like IsAdmin=true).

Better pattern in 2026: use a DTO/request type that contains only bindable fields:

public record CreateCustomerRequest(string Name, string Email);

public IActionResult Create(CreateCustomerRequest req) { /* map to Customer */ }

Filters

[ApiController]
[ServiceFilter(typeof(LogActionFilter))]
public class OrdersController : ControllerBase
{
    [HttpGet]
    [ResponseCache(Duration = 60)]
    public IActionResult Get() => Ok(/* ... */);
}

MVC filters: action, result, exception, authorization, resource. Apply via attributes (global, controller, or action). See Filters & Action Pipeline.

View location and partial views

Views/
├── Shared/
│   ├── _Layout.cshtml
│   └── _Validation.cshtml
├── Orders/
│   ├── Index.cshtml
│   └── Edit.cshtml
└── _ViewImports.cshtml

MVC's view discovery walks: Views/{Controller}/{Action}.cshtmlViews/Shared/{Action}.cshtml. Override with View("Name") or full path.

_ViewImports.cshtml and tag helpers

@using MyApp
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

Globally-scoped imports and tag-helper registrations. Reduces boilerplate at the top of each view.


Code: correct vs wrong

❌ Wrong: forgetting [ApiController]

[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    [HttpPost]
    public IActionResult Create(Order o)
    {
        // ❌ ModelState.IsValid not checked; clients get 200 with corrupted model
    }
}

✅ Correct

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    [HttpPost] public IActionResult Create(Order o) { /* automatic 400 on invalid */ }
}

❌ Wrong: binding the entity directly

public IActionResult Update(Customer c)   // ❌ over-posting (client sends IsAdmin=true)

✅ Correct: DTO

public record UpdateCustomerRequest(string Name, string Email);
public IActionResult Update(int id, UpdateCustomerRequest req) { ... }

❌ Wrong: returning raw exception details to client

catch (Exception ex)
{
    return BadRequest(ex.ToString());   // ❌ leaks stack traces
}

✅ Correct

catch (Exception ex)
{
    Logger.LogError(ex, "create failed");
    return Problem(detail: "Could not create order.", statusCode: 500);
}

Design patterns for this topic

Pattern 1 — "DTO per use case"

  • Intent: prevent over-posting; explicit contracts.

Pattern 2 — "[ApiController] for every API controller"

  • Intent: automatic validation + ProblemDetails.

Pattern 3 — "Razor Pages for traditional CRUD UIs"

  • Intent: less ceremony than MVC for page-centric apps.

Pattern 4 — "Action filters for repeated cross-cutting concerns"

  • Intent: auditing, slow-query logging, tenant context.

Pattern 5 — "PageModel binding via [BindProperty]"

  • Intent: Razor Pages property-based input.
  • Code sketch:
public class EditModel : PageModel
{
    [BindProperty] public Order Order { get; set; } = new();
    public async Task<IActionResult> OnPostAsync() { /* save Order */ }
}

Pros & cons / trade-offs

Aspect Pros Cons
MVC Mature; flexible Boilerplate for simple APIs
Razor Pages Page-centric clarity Less convention reuse for APIs
[ApiController] Auto-validation Requires you accept the conventions
ActionFilters Cross-cutting Hidden behavior

When to use / when to avoid

  • Use MVC for complex apps with action conventions (legacy or hybrid).
  • Use Razor Pages for new server-rendered UIs.
  • Use Minimal APIs for new JSON APIs.
  • Avoid binding entities directly — use DTOs.
  • Avoid writing if (!ModelState.IsValid) boilerplate — [ApiController] does it.

Interview Q&A

Q1. Difference between MVC and Razor Pages? MVC organizes by controller (collection of actions). Razor Pages organizes by page (single URL = file pair). Razor Pages is cleaner for page-centric UIs.

Q2. What does [ApiController] do? Enables automatic model validation, inferred parameter binding, ProblemDetails for error status codes, and multipart/form-data binding.

Q3. What's over-posting? Client sends extra properties (e.g., IsAdmin=true) that bind to your entity. Defense: use a DTO with only the expected fields, or [Bind("X,Y")].

Q4. How do you return a 201 Created? return Created(uri, value) or CreatedAtAction(nameof(Get), new {id}, saved) — the latter generates the URI from a route.

Q5. What's ActionResult<T>? A wrapper that lets an action return either a T (auto-wrapped in 200 OK) or any IActionResult. Better OpenAPI.

Q6. How does Razor Pages handle multiple POSTs on a page? Named handlers: OnPostDeleteAsync, OnPostUpdateAsync. Submit to ?handler=Delete or via <form asp-page-handler="Delete">.

Q7. Where does _Layout.cshtml live? Views/Shared/_Layout.cshtml (MVC) or Pages/Shared/_Layout.cshtml (Razor Pages). Set in _ViewStart.cshtml.

Q8. When would you use [FromForm] vs [FromBody]? [FromForm] for application/x-www-form-urlencoded or multipart/form-data. [FromBody] for JSON (default for complex types in [ApiController]).

Q9. What's the difference between an action filter and a result filter? Action filter wraps action method execution. Result filter wraps the result execution (e.g., view rendering). Both have before/after hooks.

Q10. Should you use convention-based or attribute-based routing? Attribute for APIs (clear, per-action). Convention for traditional MVC sites with predictable URLs.


Gotchas / common mistakes

  • ⚠️ Forgetting [ApiController] — manual validation needed.
  • ⚠️ Binding entities directly — over-posting risk.
  • ⚠️ Mixing IActionResult and Task<IActionResult> — async actions must return Task.
  • ⚠️ Returning View() from an API controller — looks for a Razor view.
  • ⚠️ PageModel state across requests — requests are independent; persist via session or DB.

Further reading