Razor Pages vs MVC is the first decision you face when building a server-rendered web app in ASP.NET Core. Both frameworks render HTML on the server with the Razor view engine and share model binding, validation, filters and tag helpers, but they organize code differently: MVC around controllers and actions, Razor Pages around individual pages. This guide explains both architectures with working examples, covers the building blocks they share, shows how to add htmx-style interactivity without a single-page app, and finishes with a practical way to choose between Razor Pages, MVC and Blazor static server-side rendering (SSR).

What Are Razor Pages and MVC?#

ASP.NET Core MVC implements the Model-View-Controller pattern. A request is routed to a controller, an action method works with the model, and the action picks a view to render. Controllers group related actions, which suits resources with many screens and shared logic, and the same controller infrastructure also powers web APIs.

Razor Pages is a page-based model built on top of MVC. The @page directive turns a .cshtml file into an endpoint that handles requests directly, without a controller. Each page usually has a PageModel class next to it, holding handler methods and the data the page renders. The result is high cohesion: everything about the "Edit product" screen lives in two adjacent files.

Because Razor Pages runs on the MVC engine, most knowledge transfers between the two, and one app can use both. Microsoft's current guidance recommends Blazor for new web UI projects, but Razor Pages and MVC remain fully supported, receive framework improvements, and can host Razor components when you need them.

How MVC and Razor Pages Work#

In MVC, routing matches the URL against conventional routes such as {controller=Home}/{action=Index}/{id?} or against attribute routes. The framework creates the controller through dependency injection, runs filters, binds the action's parameters and invokes it. The action returns an IActionResult, typically a ViewResult, and the view engine locates Views/{Controller}/{Action}.cshtml, wraps it in a layout and writes the HTML.

In Razor Pages, the route comes from the file's location under Pages, optionally extended by a template in the @page directive. Pages/Products/Edit.cshtml with @page "{id:int}" answers /Products/Edit/42. The framework creates the PageModel, selects a handler from the HTTP verb and an optional handler name, binds data and runs the handler. Returning Page() renders the page itself.

ConcernMVCRazor Pages
Request handlerController actionPage handler such as OnGet or OnPostAsync
Markup locationViews/{Controller}/Next to its PageModel under Pages/
RoutingConventional or attribute routesFolder path plus the @page template
Form dataAction parameters[BindProperty] properties or handler parameters
FiltersAction, result and exception filtersPage filters (IPageFilter, IAsyncPageFilter)
AntiforgeryEnabled through filtersValidated automatically
Natural fitResources with many related viewsSelf-contained screens and forms

Getting Started: Razor Pages and MVC in One App#

Both frameworks register in Program.cs. Note AddControllersWithViews: plain AddControllers is for APIs and does not add view or antiforgery support.

C#
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<ShopDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Shop")));
builder.Services.AddRazorPages();
builder.Services.AddControllersWithViews(options =>
{
    // Require antiforgery tokens on every unsafe MVC request (POST, PUT, DELETE...).
    options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute());
});

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

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

app.Run();

MapStaticAssets and WithStaticAssets, used by the .NET 9 and later templates, serve static files with build-time gzip and Brotli compression, content-based ETags and fingerprinting. They are a drop-in replacement for UseStaticFiles in most apps.

Page Models and Handlers in Razor Pages#

Here is a complete edit screen. The markup uses tag helpers to bind inputs and render validation messages:

Razor
@page "{id:int}"
@model EditModel
@{
    ViewData["Title"] = "Edit product";
}

<h1>Edit product</h1>

<form method="post">
    <div asp-validation-summary="ModelOnly" class="text-danger"></div>

    <label asp-for="Input.Name"></label>
    <input asp-for="Input.Name" />
    <span asp-validation-for="Input.Name" class="text-danger"></span>

    <label asp-for="Input.Price"></label>
    <input asp-for="Input.Price" />
    <span asp-validation-for="Input.Price" class="text-danger"></span>

    <label asp-for="Input.SalePrice"></label>
    <input asp-for="Input.SalePrice" />
    <span asp-validation-for="Input.SalePrice" class="text-danger"></span>

    <button type="submit">Save</button>
</form>

@section Scripts {
    <partial name="_ValidationScriptsPartial" />
}

The page model loads data on GET, validates and saves on POST, and redirects after success:

C#
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;

namespace Shop.Pages.Products;

public sealed class EditModel(ShopDbContext db) : PageModel
{
    [BindProperty]
    public ProductInput Input { get; set; } = new();

    [TempData]
    public string? StatusMessage { get; set; }

    public async Task<IActionResult> OnGetAsync(int id, CancellationToken ct)
    {
        var product = await db.Products.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id, ct);
        if (product is null) return NotFound();

        Input = new ProductInput
        {
            Name = product.Name, Price = product.Price, SalePrice = product.SalePrice
        };
        return Page();
    }

    public async Task<IActionResult> OnPostAsync(int id, CancellationToken ct)
    {
        if (!ModelState.IsValid) return Page(); // redisplay with validation messages

        var product = await db.Products.FindAsync([id], ct);
        if (product is null) return NotFound();

        product.Name = Input.Name;
        product.Price = Input.Price;
        product.SalePrice = Input.SalePrice;
        await db.SaveChangesAsync(ct);

        StatusMessage = $"Saved {product.Name}.";
        return RedirectToPage("./Index"); // Post/Redirect/Get
    }
}

// Only the fields a user may edit, which prevents overposting.
public sealed class ProductInput : IValidatableObject
{
    [Required, StringLength(80)]
    public string Name { get; set; } = "";

    [Range(0.01, 100_000)]
    public decimal Price { get; set; }

    [Display(Name = "Sale price")]
    public decimal? SalePrice { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext context)
    {
        if (SalePrice >= Price)
        {
            yield return new ValidationResult(
                "The sale price must be lower than the price.", [nameof(SalePrice)]);
        }
    }
}

Handlers are selected by convention: OnGet, OnPost, OnPut and so on, with an optional Async suffix. A page can also expose named handlers such as OnPostArchiveAsync. A button with asp-page-handler="Archive" posts to the same page with ?handler=Archive in the URL, and you will see one in the htmx section below. [BindProperty] binds only on non-GET requests unless you set SupportsGet = true, which keeps query strings from silently populating form models. The [TempData] message survives the redirect and is shown once by the layout.

Controllers, Actions and Views in MVC#

The equivalent MVC controller groups the list and edit actions for products. Views live in Views/Products/ and use exactly the same tag helpers, typed with @model ProductInput.

C#
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace Shop.Controllers;

[Route("admin/products")]
public sealed class ProductsController(ShopDbContext db) : Controller
{
    [HttpGet("")]
    public async Task<IActionResult> Index(CancellationToken ct) =>
        View(await db.Products.AsNoTracking().OrderBy(p => p.Name).ToListAsync(ct));

    [HttpGet("{id:int}/edit")]
    public async Task<IActionResult> Edit(int id, CancellationToken ct)
    {
        var product = await db.Products.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id, ct);
        return product is null
            ? NotFound()
            : View(new ProductInput
            {
                Name = product.Name, Price = product.Price, SalePrice = product.SalePrice
            });
    }

    [HttpPost("{id:int}/edit")]
    public async Task<IActionResult> Edit(int id, ProductInput input, CancellationToken ct)
    {
        if (!ModelState.IsValid) return View(input);

        var product = await db.Products.FindAsync([id], ct);
        if (product is null) return NotFound();

        product.Name = input.Name;
        product.Price = input.Price;
        product.SalePrice = input.SalePrice;
        await db.SaveChangesAsync(ct);

        TempData["StatusMessage"] = $"Saved {product.Name}.";
        return RedirectToAction(nameof(Index));
    }
}

Compared with the page model, the controller spreads one screen across an action pair and a view in another folder. That is extra ceremony for a single form, but it pays off when many actions share dependencies, filters and authorization rules, or when the same controller family also serves JSON. MVC action filters can target individual actions. Razor Pages filters apply to a whole page and cannot target a single handler.

Layouts, Partials, Tag Helpers and View Components#

Both frameworks share the Razor view features that keep markup consistent. _ViewImports.cshtml shares @using and @addTagHelper directives with every file in its folder tree. _ViewStart.cshtml sets the default layout, and the layout renders each page at @RenderBody(), with optional sections filled by pages. Razor Pages apps should keep shared layouts in Pages/Shared rather than Views/Shared.

Razor
@* Pages/_ViewImports.cshtml *@
@using Shop
@namespace Shop.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, Shop

@* Pages/Shared/_Layout.cshtml *@
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <title>@ViewData["Title"] - Contoso Shop</title>
    <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
</head>
<body>
    <header>
        <a asp-page="/Index">Contoso Shop</a>
        <vc:cart-summary max-items="3"></vc:cart-summary>
    </header>
    <main>
        @if (TempData["StatusMessage"] is string message)
        {
            <alert variant="success">@message</alert>
        }
        @RenderBody()
    </main>
    <partial name="_Footer" />
    <script src="~/lib/htmx/htmx.min.js" asp-append-version="true"></script>
    @await RenderSectionAsync("Scripts", required: false)
</body>
</html>

Partial views, rendered with <partial name="...">, reuse markup that needs no logic of its own. Tag helpers are C# classes that participate in rendering HTML elements. The built-in set covers forms, inputs, labels, validation, links (asp-page, asp-action, asp-route-*), environment-specific markup, fragment caching with <cache>, and cache-busting with asp-append-version. Custom tag helpers are small and easy to write. Pascal-case names map to kebab-case markup, so this class handles <alert variant="success">:

C#
using Microsoft.AspNetCore.Razor.TagHelpers;

namespace Shop.TagHelpers;

[HtmlTargetElement("alert")]
public sealed class AlertTagHelper : TagHelper
{
    public string Variant { get; set; } = "info";
    public bool Dismissible { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        output.TagName = "div";
        output.TagMode = TagMode.StartTagAndEndTag;
        output.Attributes.SetAttribute("class", $"alert alert-{Variant}");
        output.Attributes.SetAttribute("role", "alert");

        if (Dismissible)
        {
            output.PostContent.AppendHtml(
                """<button type="button" class="btn-close" aria-label="Close"></button>""");
        }
    }
}

View components are the next step up: reusable UI with its own logic and dependencies, such as a cart summary, a navigation menu or a "recently viewed" panel. They do not take part in model binding. Their parameters come from the caller, whether that is <vc:cart-summary max-items="3"> or await Component.InvokeAsync("CartSummary", new { maxItems = 3 }).

C#
using Microsoft.AspNetCore.Mvc;

namespace Shop.ViewComponents;

public sealed class CartSummaryViewComponent(ICartService carts) : ViewComponent
{
    public async Task<IViewComponentResult> InvokeAsync(int maxItems = 3)
    {
        var cart = await carts.GetCurrentAsync(HttpContext.RequestAborted);

        // Renders Pages/Shared/Components/CartSummary/Default.cshtml
        // (or Views/Shared/Components/CartSummary/Default.cshtml in MVC).
        return View(cart.Items.Take(maxItems).ToList());
    }
}

The name comes from the class name minus the ViewComponent suffix. Page handlers and controller actions can also return a view component directly with ViewComponent("CartSummary", ...), which is handy for htmx requests that refresh one widget.

Model Binding and Validation#

Model binding reads form fields, route values, query strings and headers and converts them into typed handler parameters or [BindProperty] properties. Validation then runs against DataAnnotations attributes such as [Required], [StringLength] and [Range], plus IValidatableObject for cross-field rules like the sale-price check above. Results land in ModelState, and the pattern is always the same: if ModelState.IsValid is false, redisplay the form.

A few behaviors surprise newcomers. With nullable reference types enabled, MVC treats non-nullable properties as if they had [Required(AllowEmptyStrings = true)], so a missing string Name fails validation even without an attribute. Client-side validation comes from jQuery Validation and its unobtrusive adapter, included through _ValidationScriptsPartial. It improves the experience but never replaces server validation, because any client can post anything. [Remote] adds server-backed checks, such as "is this username taken?", to client-side validation.

Overposting is the classic binding vulnerability. If you bind an entity directly, an attacker can add IsAdmin=true or Price=0 to the form and the binder will set those properties. Bind to input models that contain only editable fields, as ProductInput does, then copy the values onto the entity. The [Bind] attribute works for create forms but is awkward for edits, because excluded properties are reset instead of left unchanged.

Anti-Forgery Protection in Razor Pages and MVC#

Cross-site request forgery (CSRF) tricks a signed-in user's browser into submitting a request to your site from another origin, riding on the user's cookies. ASP.NET Core defends with the synchronizer token pattern: the form tag helper adds a hidden __RequestVerificationToken field to every method="post" form, and the server compares it with a matching cookie.

  • Razor Pages validates tokens automatically on unsafe requests. There is nothing to configure.
  • MVC needs filters. [ValidateAntiForgeryToken] checks every request to the actions it covers, including GET. [AutoValidateAntiforgeryToken] skips GET, HEAD, OPTIONS and TRACE, and Microsoft recommends applying it globally in non-API apps, as the Program.cs above does. [IgnoreAntiforgeryToken] opts out specific actions.
  • JavaScript requests send the token in the RequestVerificationToken header, the default header name. Get the value in a view from IAntiforgery.GetAndStoreTokens(HttpContext).RequestToken.

ASP.NET Core in .NET 11, currently at release candidate stage, adds an automatic CSRF middleware that inspects the browser-controlled Sec-Fetch-Site and Origin headers and rejects untrusted cross-origin form posts. It is enabled by default in apps built with WebApplication.CreateBuilder and complements the token system rather than replacing it. Razor Pages and MVC apps gain a header-based check without changing their token flow. For the wider threat picture, see OWASP Top 10 for .NET developers.

Progressive Enhancement with htmx#

Many server-rendered apps need a little interactivity: inline edits, live search, a row that updates without a full reload. htmx, a small JavaScript library that is not part of .NET, fits Razor Pages and MVC well. Its hx-* attributes issue requests and swap the returned HTML into the page, and your handlers already produce HTML. The key technique is progressive enhancement: build a normal form that works without JavaScript, then let htmx upgrade it.

Razor
@* Pages/Products/Index.cshtml *@
@page
@model IndexModel

<table>
    <tbody>
        @foreach (var product in Model.Products)
        {
            <partial name="_ProductRow" model="product" />
        }
    </tbody>
</table>

@* Pages/Products/_ProductRow.cshtml *@
@model ProductSummary
<tr>
    <td>@Model.Name</td>
    <td>@(Model.IsArchived ? "Archived" : "Active")</td>
    <td>
        <form method="post" asp-page-handler="Archive" asp-route-id="@Model.Id"
              hx-post="@Url.Page("./Index", "Archive", new { id = Model.Id })"
              hx-target="closest tr" hx-swap="outerHTML">
            <button type="submit" disabled="@Model.IsArchived">Archive</button>
        </form>
    </td>
</tr>

Without JavaScript, the form posts normally. With htmx loaded, the same form sends an AJAX request whose hx-post mirrors the action, and it swaps only the table row. The form still contains the hidden antiforgery field, so htmx sends the token and Razor Pages validates it as usual. The named handler decides what to return based on the HX-Request header, which htmx adds to its requests:

C#
public async Task<IActionResult> OnPostArchiveAsync(int id, CancellationToken ct)
{
    var product = await db.Products.FindAsync([id], ct);
    if (product is null) return NotFound();

    product.IsArchived = true;
    await db.SaveChangesAsync(ct);

    if (Request.Headers["HX-Request"] == "true")
    {
        // htmx request: return only the updated row fragment.
        return Partial("_ProductRow", new ProductSummary(product.Id, product.Name, true));
    }

    return RedirectToPage(); // No JavaScript: classic Post/Redirect/Get.
}

For elements outside a form, send the token with hx-headers using the RequestVerificationToken header. If a GET endpoint returns either a full page or a fragment depending on HX-Request, add Vary: HX-Request so caches keep the two apart, and follow the htmx guidance to set htmx.config.historyRestoreAsHxRequest to false. Since .NET 8, RazorComponentResult also lets an endpoint return a Razor component as an HTML fragment, so Blazor components can double as htmx partials.

Performance of Server-Rendered Razor Apps#

Razor files compile to C# classes at build and publish time, so there is no view parsing at runtime. Razor runtime compilation, once used to see markup changes without rebuilding, was obsoleted in .NET 10. It also disables Hot Reload, which is now the recommended way to iterate on markup during development.

Most real-world slowness comes from data access and caching, not from Razor itself:

  • Query once in the handler, project to a view model with AsNoTracking(), and never lazy-load inside a view loop, which is a classic N+1 trap.
  • Keep everything async. View components and tag helpers support async work, so there is no reason to block.
  • Cache whole pages with output caching and fragments with the <cache> tag helper, which varies by route, query, user or header.
C#
// Program.cs
builder.Services.AddOutputCache(options =>
{
    options.AddPolicy("Catalog", policy => policy
        .Expire(TimeSpan.FromMinutes(5))
        .SetVaryByQuery("page", "sort")
        .Tag("catalog"));
});

// In the pipeline: after UseRouting, UseAuthentication and UseAuthorization.
app.UseOutputCache();

// Pages/Catalog/Index.cshtml.cs: Razor Pages apply the attribute to the page class.
[OutputCache(PolicyName = "Catalog")]
public sealed class IndexModel(CatalogQueries queries) : PageModel
{
    public IReadOnlyList<CatalogItem> Items { get; private set; } = [];

    public async Task OnGetAsync(int page, string? sort, CancellationToken ct) =>
        Items = await queries.GetPageAsync(page, sort, ct);
}

The default policy caches only GET and HEAD requests that return 200, and skips authenticated requests and responses that set cookies, so enabling it is safe. When a product changes, evict the catalog tag through IOutputCacheStore. The caching and rate limiting guide covers tags, Redis storage and HybridCache in depth.

Best Practices#

  • Use input models for every form. Bind only what users may change, and map onto entities explicitly.
  • Follow Post/Redirect/Get. Redirect after a successful post, so refreshing never resubmits, and use TempData for the confirmation.
  • Apply AutoValidateAntiforgeryToken globally in MVC. Opting in per action eventually misses one.
  • Keep page models thin. Push business rules into services so handlers only bind, validate, call and return.
  • Pick the right reuse tool. Use partials for markup, tag helpers for element behavior, and view components for UI with its own data.
  • Pass cancellation tokens from handlers into EF Core and HTTP calls, so abandoned requests stop consuming resources.
  • Test through HTTP. Use WebApplicationFactory to exercise routing, binding, antiforgery and rendering together, as the integration testing guide shows.

Common Pitfalls#

  • Binding entities directly, which opens overposting holes.
  • Forgetting ModelState.IsValid, which saves invalid data because validation errors do not throw.
  • Using AddControllers for a view-based app, which silently loses view and antiforgery support.
  • Changing state on GET requests, which bypasses antiforgery protection and can be triggered by prefetching or crawlers.
  • Querying inside views or per-row view components, which multiplies database round trips.
  • Mixing Views/Shared and Pages/Shared layouts without a plan, which leads to confusing lookup rules.
  • Enabling runtime compilation in production, which is obsolete and slower than build-time compilation.

Razor Pages vs MVC vs Blazor SSR: Which Should You Choose?#

Since .NET 8, Blazor Web Apps can render components statically on the server, with enhanced navigation, enhanced form handling ([SupplyParameterFromForm], FormName) and streaming rendering, and can opt individual components into interactivity. That makes Blazor SSR a third server-rendered option.

CriterionRazor PagesMVCBlazor static SSR
Unit of organizationPage plus PageModelController, actions and viewsRazor components
RoutingFolder path and @page templatesConventional or attribute routes@page in components
ReusePartials, tag helpers, view componentsSame as Razor PagesComponents with parameters
InteractivityAdd JavaScript or htmxAdd JavaScript or htmxEnhanced navigation, opt-in interactive render modes
Forms and CSRFAutomatic token validationFilter-based validationEditForm with antiforgery middleware
Best fitForm-heavy CRUD, content and admin sitesLarge apps with shared action logic, mixed HTML and APIsNew apps, component libraries, growing interactivity

Choose Razor Pages for page-centric apps: back offices, account portals, content and form-heavy sites. Choose MVC when a team already knows it, when many actions share filters and dependencies per resource, or when you are modernizing an ASP.NET MVC 5 application. Choose Blazor for new projects where a component model and a path to rich interactivity matter; the Blazor guide covers render modes in detail. Mixing is normal: an MVC or Razor Pages app can render Razor components, and a Blazor app can keep Razor Pages for Identity screens.

Frequently Asked Questions#

Is Razor Pages replacing MVC in ASP.NET Core?#

No. Razor Pages is built on the MVC framework, and both are supported side by side, even in the same app. Razor Pages is the simpler default for page-focused UI, while MVC remains a good fit for controller-centric designs and for apps that mix HTML views with API endpoints.

Can I use Razor Pages and MVC controllers in the same project?#

Yes. Call AddRazorPages and AddControllersWithViews, then map both with MapRazorPages and MapControllerRoute. They share layouts, tag helpers, view components, filters and dependency injection, so teams often keep MVC for existing areas while building new screens as pages.

Do I still need jQuery for validation?#

For built-in client-side validation in Razor Pages and MVC, yes: the templates use jQuery Validation with the unobtrusive adapter. Server-side validation works without any JavaScript, and many teams pair it with htmx to re-render forms with errors instead of relying on client-side rules.

Is htmx a good alternative to a JavaScript SPA framework?#

For form-driven and CRUD-heavy apps, often yes. htmx keeps rendering and state on the server, reuses your existing handlers and partials, and adds very little JavaScript. For offline support, complex client-side state or highly interactive editors, a SPA or Blazor's interactive render modes are a better fit.

Should new projects use Blazor instead of Razor Pages?#

Microsoft recommends Blazor for new web UI, and static SSR removes much of the old gap for server-rendered pages. Razor Pages is still a sound choice when your team knows it, when you rely on the tag helper and view component ecosystem, or when the app is mostly forms with little interactivity.

Summary#

  • MVC organizes code around controllers and views, while Razor Pages organizes it around pages with page models. Both share the same engine and features.
  • Handlers, [BindProperty] input models, ModelState validation and Post/Redirect/Get form the core Razor Pages workflow.
  • Layouts, partials, tag helpers and view components provide layered reuse in both frameworks.
  • Razor Pages validates antiforgery tokens automatically, while MVC needs AutoValidateAntiforgeryToken. .NET 11 adds header-based CSRF checks on top.
  • htmx adds interactivity with progressive enhancement, and output caching plus static asset optimization cover most performance needs.
  • Use Razor Pages for page-centric apps, MVC for controller-centric ones, and Blazor SSR for new component-based projects.

Further Reading#