Razor Pages: PageModel, Handler Methods, Page Routing and When to Use It Instead of MVC
With @page a Razor file becomes an endpoint, and PageModel handlers such as OnGetAsync and OnPostDeleteAsync replace actions. We rewrite Bookstore's admin CRUD and examine SupportsGet, TempData, AuthorizeFolder and page filters, why an unmatched handler ends in a 500, and the file layout versus MVC, with real output.
Version note: The code in this post was built and run with .NET 10 (SDK 10.0.402) and Entity Framework Core (EF Core) 10.0.14 (SQLite).
Guess First
Bookstore's admin panel has a Razor Page that lists books. Its PageModel has two handlers: OnGetAsync, which fills the list, and OnPostDeleteAsync, which deletes a book. A signed-in editor sends this request with a valid antiforgery token: POST /Manage/Books?handler=Archive. There is no OnPostArchiveAsync method. What happens: a 404, a 405, or does the page quietly render again? Now imagine sending a request with curl and no token. Which status code do you expect? Both answers are in this post, from real runs.
@page: The File Itself Is the Endpoint
In the first seven posts of this series we built the MVC (Model-View-Controller) side: Areas/Admin/Controllers/BooksController, its views, BookFormViewModel and the PRG (Post-Redirect-Get) flow. Now we write the same admin screen with Razor Pages and keep both models side by side in the same project. The MVC admin area stays under /Admin/Books; the Razor Pages version lives under /Manage/Books.
The core idea of Razor Pages fits in one sentence: put @page on the first line of a .cshtml file and the file becomes an endpoint on its own. There is no controller in between; the file's location in the Pages folder decides the URL (Uniform Resource Locator):
Pages/Manage/Books/Index.cshtml -> /Manage/Books, /Manage/Books/Index
Pages/Manage/Books/Create.cshtml -> /Manage/Books/Create
Pages/Manage/Books/Edit.cshtml -> /Manage/Books/Edit/{id:int}
Pages/Account/Login.cshtml -> /Account/Login
Index is the default page of every folder, so /Manage/Books lands straight on the list. On the Edit page we write the route template into the file itself:
@page "{id:int}"
@model Bookstore.Web.Pages.Manage.Books.EditModel
@{
ViewData["Title"] = "Edit book";
}
<h1>Edit book</h1>
<partial name="_BookForm" for="Input" />
The {id:int} constraint is the same kind of constraint we saw in the routing post of the Web API (Application Programming Interface) series. A request to /Manage/Books/Edit/abc never reaches the handler; routing rejects it and returns 404. @page must always be the first Razor directive in the file. When I put it below @model, the build stopped with this error:
error RZ3906: The '@page' directive must precede all other elements defined in a Razor file.
Setup takes two lines in Program.cs: AddRazorPages() on the services and MapRazorPages() on the pipeline. Our project started from dotnet new mvc, so AddControllersWithViews() was already there; both share the same routing infrastructure and don't clash. When Razor Pages looks for a layout it checks Pages/Shared first and then Views/Shared, so the Layout = "_Layout" line in Pages/_ViewStart.cshtml finds the MVC side's _Layout.cshtml. Both sides keep the same look.
PageModel and Handler Methods
The class behind the page derives from PageModel and lives in a file named after the page with .cs appended (Index.cshtml.cs). Handler methods take the place of controller actions:
namespace Bookstore.Web.Pages.Manage.Books;
public class IndexModel(IBookCatalog catalog, IBookAdminService admin) : PageModel
{
[BindProperty(SupportsGet = true)]
public string? Search { get; set; }
[BindProperty(SupportsGet = true)]
public int PageNumber { get; set; } = 1;
[TempData]
public string? StatusMessage { get; set; }
public BookCatalogViewModel Catalog { get; private set; } = null!;
public async Task OnGetAsync(CancellationToken ct)
{
Catalog = await catalog.ListAsync(Search, PageNumber, ct: ct);
}
public async Task<IActionResult> OnPostDeleteAsync(int id, CancellationToken ct)
{
bool deleted = await admin.DeleteAsync(id, ct);
if (!deleted)
{
return NotFound();
}
StatusMessage = $"Book #{id} deleted.";
return RedirectToPage(new { Search, PageNumber });
}
}
The naming rule is On + the HTTP (Hypertext Transfer Protocol) verb + an optional handler name + an optional Async. OnGetAsync is the unnamed GET handler; OnPostDeleteAsync is the POST handler named Delete. Dependencies arrive through the primary constructor; DI (Dependency Injection) works exactly as it does for controllers, because a new IndexModel instance is created for every request.
In the view, the asp-page-handler tag helper attribute picks the handler:
<form method="post" asp-page-handler="Delete" asp-route-id="@book.Id">
<button type="submit">Delete</button>
</form>
The rendered HTML (HyperText Markup Language) looks like this (token shortened):
<form method="post" action="/Manage/Books?id=1&handler=Delete">
<button type="submit">Delete</button>
<input name="__RequestVerificationToken" type="hidden" value="CfDJ8EINq_wm4TFC..." /></form>
The handler name travels in the query string as handler=Delete. If you don't want it there, move it into a route segment with @page "{handler?}"; the address then becomes /Manage/Books/Delete?id=1. Here is the part to watch: as soon as the form tag helper sees method="post" it adds the antiforgery token as a hidden input, and Razor Pages validates that token by default on every request except GET, HEAD, OPTIONS and TRACE. In MVC you needed [ValidateAntiForgeryToken] or a global AutoValidateAntiforgeryTokenAttribute; in Razor Pages you write nothing extra.
That is exactly the answer to the second question. The curl request without a token never reaches the handler:
$ curl -i -X POST "http://localhost:17810/Manage/Books?id=6&handler=Delete"
HTTP/1.1 400 Bad Request
Content-Length: 0
A 400 with an empty body. The template's appsettings.Development.json raises the Microsoft.AspNetCore category to Warning, so the log shows nothing either. Lower that category to Information and the reason appears:
info: Microsoft.AspNetCore.Mvc.ViewFeatures.Filters.AutoValidateAntiforgeryTokenAuthorizationFilter[1]
Antiforgery token validation failed. The required antiforgery cookie ".AspNetCore.Antiforgery.k61VYwP4eJ0" is not present.
Note the word AutoValidate in the filter name: what Razor Pages adds on its own is the same global filter you add by hand in MVC. That's why everyone who tries a Razor Pages endpoint with curl or Postman for the first time spends a while asking "why do I get a 400".
How Handler Selection Works
Now the first question. The framework first collects the handlers that match the HTTP method, then looks at the handler value. With a valid token and session, sending ?handler=Archive gave this real result:
HTTP/1.1 500 Internal Server Error
warn: Bookstore.Web.Filters.AuditPageFilter[0]
No handler matched POST /Manage/Books/Index
fail: Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware[1]
An unhandled exception has occurred while executing the request.
System.NullReferenceException: Object reference not set to an instance of an object.
at Bookstore.Web.Pages.Manage.Books.Pages_Manage_Books_Index.ExecuteAsync() in .../Pages/Manage/Books/Index.cshtml:line 20
Not 404, not 405: 500. When no handler matches, Razor Pages doesn't fail; it renders the page without running any handler. The Catalog property is only filled inside OnGetAsync, so the @foreach (... in Model.Catalog.Books) line blows up. In MVC a request to a missing action gets a 404; in Razor Pages the page itself exists, only the handler is missing.
The reverse is interesting too: GET /Manage/Books?handler=Delete&id=1 returns 200 and shows the list. There is no OnGetDelete method, so the unnamed OnGetAsync is selected. The unnamed handler acts as a fallback for requests whose name doesn't match. The practical takeaway: don't leave the data a page needs to a single handler, or initialize properties like Catalog so they can't be null. We log the HandlerMethod is null case at warn level in AuditPageFilter; in production, that line is the first thing that tells you someone mistyped a handler name in a form.
[BindProperty], SupportsGet and TempData
In MVC, input arrives as action parameters. In Razor Pages it is usually bound to a PageModel property: a property marked [BindProperty] is filled from form values. The important default: [BindProperty] only works on non-GET requests. If you want to bind from the query string on GET, you opt in explicitly with SupportsGet = true. Microsoft's documentation makes this opt-in for security reasons; the user can write the query string however they like, and the decision "bind on GET too" should be a conscious one. It suits harmless values like a search box and a page number, not the entity itself.
There is a reason I called the page number property PageNumber and not Page. PageModel already has a method called Page(), and when I gave the property that name the compiler warned:
warning CS0108: 'PModel.Page' hides inherited member 'PageModel.Page()'. Use the new keyword if hiding was intended.
The [TempData] attribute binds a property to TempData. The delete handler writes StatusMessage and returns RedirectToPage; the cookie-based TempData provider carries the value to the next GET request and removes it as soon as it is read:
HTTP/1.1 302 Found
Location: /Manage/Books?Search=Draft&PageNumber=1
Set-Cookie: .AspNetCore.Mvc.CookieTempDataProvider=CfDJ8EINq_wm4TFC...; path=/; samesite=lax; httponly
<p class="alert alert-success">Book #6 deleted.</p>
RedirectToPage(new { Search, PageNumber }) returns to the current page with route values, so after a delete the user keeps seeing the same search result and the same page. We covered the reasoning behind PRG in the forms post of this series; the only thing that changes here is the helper names: RedirectToPage instead of RedirectToAction, Page() instead of View().
Create and Edit: Forms, Validation and Page()
The new-book page is MVC's Create GET and POST action pair collected into a single file:
public class CreateModel(IBookAdminService admin) : PageModel
{
[BindProperty]
public BookFormViewModel Input { get; set; } = new();
[TempData]
public string? StatusMessage { get; set; }
public async Task OnGetAsync(CancellationToken ct)
{
Input.Authors = await admin.GetAuthorOptionsAsync(ct);
}
public async Task<IActionResult> OnPostAsync(CancellationToken ct)
{
if (!ModelState.IsValid)
{
// if we don't refill the select list on POST, the dropdown comes back empty
Input.Authors = await admin.GetAuthorOptionsAsync(ct);
return Page();
}
int id = await admin.CreateAsync(Input, ct);
StatusMessage = $"Book #{id} created.";
return RedirectToPage("Index");
}
}
We reuse BookFormViewModel from the MVC side as is. The validation attributes, the data-val-* attributes generated for client-side validation and the ModelState behavior are the same as in the validation post. To share the form between two pages we call the _BookForm.cshtml partial with for="Input". Had we written model="Model.Input", the inputs would render as name="Title"; with for, the property path is kept as a prefix:
<input type="text" data-val="true" data-val-length="The field Title must be a string with a maximum length of 120." data-val-length-max="120" data-val-required="The Title field is required." id="Input_Title" maxlength="120" name="Input.Title" value="Rivers of Ink" />
The unprefixed names happened to work on this page anyway: when the model binder finds no value with the Input. prefix, it falls back to the empty prefix. But the day you add another bound property like Search to the same page, the names can collide. On an invalid POST the page is re-rendered at the same URL with Page(), and the error messages land under the right fields:
<span class="field-validation-error" data-valmsg-for="Input.Title" data-valmsg-replace="true">The Title field is required.</span>
<span class="field-validation-error" data-valmsg-for="Input.Isbn" data-valmsg-replace="true">ISBN must be 13 digits.</span>
<span class="field-validation-error" data-valmsg-for="Input.Price" data-valmsg-replace="true">The field Price must be between 0.01 and 10000.</span>
Page Conventions: AuthorizeFolder and Page Filters
With controllers you usually grant access by putting [Authorize] on the class. You can put [Authorize] on a PageModel too, but the really powerful tool is conventions: applying a rule to an entire folder with one line in Program.cs.
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options => options.LoginPath = "/Account/Login");
builder.Services.AddScoped<AuditPageFilter>();
builder.Services.AddRazorPages(options =>
{
// every page under /Manage requires a signed-in user
options.Conventions.AuthorizeFolder("/Manage");
// a page filter from DI for the same folder
options.Conventions.AddFolderApplicationModelConvention("/Manage",
model => model.Filters.Add(new ServiceFilterAttribute(typeof(AuditPageFilter))));
});
A new page becomes protected the moment it's added under Pages/Manage; the risk of someone forgetting [Authorize] disappears. An anonymous request is redirected to the login page:
$ curl -i http://localhost:17810/Manage/Books/Edit/3
HTTP/1.1 302 Found
Location: http://localhost:17810/Account/Login?ReturnUrl=%2FManage%2FBooks%2FEdit%2F3
The login page itself is a demo that issues a cookie for a fixed user. The depth of authentication and cookie settings belongs to the upcoming Authentication and Authorization series. One more rule you should know: in a folder protected with AuthorizeFolder("/Private") you can open a single page with AllowAnonymousToPage("/Private/Public"), but the reverse doesn't work. If you apply AuthorizePage to a page inside a folder opened for anonymous access, AllowAnonymousFilter wins and the page stays open.
Page filters are the Razor Pages counterpart of MVC action filters. The action filters from the filters post of the Web API series are not applied to handler methods; Razor Pages has its own interfaces:
namespace Bookstore.Web.Filters;
public class AuditPageFilter(ILogger<AuditPageFilter> logger) : IAsyncPageFilter
{
public Task OnPageHandlerSelectionAsync(PageHandlerSelectedContext context)
{
// a handler was selected, but model binding hasn't run yet
if (context.HandlerMethod is null)
{
logger.LogWarning("No handler matched {Method} {Path}", context.HttpContext.Request.Method,
context.ActionDescriptor.ViewEnginePath);
}
return Task.CompletedTask;
}
public async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next)
{
PageHandlerExecutedContext executed = await next();
logger.LogInformation("{User} ran {Handler} on {Page} -> {Result}",
context.HttpContext.User.Identity?.Name ?? "anonymous",
context.HandlerMethod?.MethodInfo.Name,
context.ActionDescriptor.ViewEnginePath,
executed.Result?.GetType().Name ?? "PageResult");
}
}
Real log lines from one session:
info: Bookstore.Web.Filters.AuditPageFilter[0]
ada ran OnGetAsync on /Manage/Books/Index -> PageResult
info: Bookstore.Web.Filters.AuditPageFilter[0]
ada ran OnPostDeleteAsync on /Manage/Books/Index -> RedirectToPageResult
warn: Bookstore.Web.Filters.AuditPageFilter[0]
No handler matched POST /Manage/Books/Index
info: Bookstore.Web.Filters.AuditPageFilter[0]
ada ran OnPostAsync on /Manage/Books/Edit -> PageResult
Know two limits. First, a page filter can't be applied to a single handler method; it applies to the whole page, a folder or globally. If you need something specific to the delete handler, check context.HandlerMethod?.Name inside the filter. Second, if one class implements both IPageFilter and IAsyncPageFilter, the framework only calls the async one; pick one.
File Layout: MVC or Razor Pages?
How the same CRUD (Create, Read, Update, Delete) screen is spread across files in the two models:
| Task | MVC (Admin area) | Razor Pages (Manage) |
|---|---|---|
| List | BooksController.Index + Views/Books/Index.cshtml | Pages/Manage/Books/Index.cshtml + .cs |
| Create | Create GET/POST actions + Create.cshtml | Create.cshtml + CreateModel (OnGetAsync, OnPostAsync) |
| Edit | Edit GET/POST actions + Edit.cshtml | Edit.cshtml (@page "{id:int}") + EditModel |
| Delete | Delete POST action | OnPostDeleteAsync named handler |
| What decides the URL | conventional route or attribute route | the file's location in the folder + the @page template |
| Authorization | [Authorize] on the controller/action | the AuthorizeFolder convention or [Authorize] |
To change a screen in MVC you usually visit three places: controller, view, ViewModel. In Razor Pages two files sit side by side and the page's whole behavior is there. Microsoft's documentation on choosing a UI (User Interface) technology also highlights, among Razor Pages' benefits, that page-specific logic and view models stay in their own folder, organized more simply than in MVC. The same document recommends Blazor first for new projects; we discussed when that choice makes sense in the first post of this series.
My practical rule: if screens are independent and each is as simple as "one form, one list" (admin panels, internal tools, settings pages), Razor Pages means less ceremony. If there are many actions over the same resource, if the same controller returns both HTML and JSON (JavaScript Object Notation), or if the team wants to keep the controller habits from the Web API side, MVC feels more natural. Mixing both in the same project is perfectly legitimate; in our Bookstore the public catalog is MVC and /Manage is Razor Pages.
Production Notes
Keep handlers thin. A PageModel can suffer from exactly the "fat controller" problem a controller does. In our example data access lives in IBookCatalog and IBookAdminService; the PageModel only translates HTTP into service calls. The separation from the layered architecture post of the Web API series applies here too.
Monitor named handlers. A log line like the No handler matched warning above points to a mistyped asp-page-handler value or to old JavaScript still calling a removed handler. If the page depends on data, these requests turn into 500s; if not, into an empty page nobody notices.
Only enable [BindProperty(SupportsGet = true)] for harmless fields like filters. As a general rule, every bound property is an entry point the user can change. The reasoning behind overposting was covered in the forms post; in Razor Pages the risk is a bit larger, because binding can also be declared at class level ([BindProperties]), and then every public property of the PageModel becomes bindable.
Don't turn antiforgery off. If you call a Razor Pages handler from JavaScript (with fetch or htmx), the token has to be sent as a header; we'll do that in the htmx post. Saying "just make it work for now" with [IgnoreAntiforgeryToken] means removing CSRF (Cross-Site Request Forgery) protection from that page entirely.
Common Pitfalls
Filling data only in OnGet and returning Page() on POST. On a validation error the page is rendered again but OnGetAsync doesn't run. The dropdown's list, the table on the page, the sidebar data stay null. Either refill them in the POST handler or move them into a shared private method.
Naming a property Page. It hides the Page() method with a CS0108 warning, and the return Page(); line no longer calls what you expect. Choose a name like PageNumber or CurrentPage.
Testing with a tokenless request and mistaking the 400 for a bug. Razor Pages validates POST by default. In integration tests you first need to GET the token and the cookie, then POST.
Using the folder convention and [AllowAnonymous] in the wrong order. You can't close a single page inside an open folder; if needed, move the page into a protected folder.
Ignoring the slash in a page name. RedirectToPage("Index") goes to the Index in the same folder (Location: /Manage/Books). RedirectToPage("/Index") looks for Pages/Index.cshtml at the root. In our project the root page is MVC's HomeController, and no such Razor Page exists. You'd expect an exception, but the real response is this:
HTTP/1.1 302 Found
Location: /?page=%2FIndex
When URL generation finds no matching page it falls through to the conventional route and appends the page value to the query string. The user lands on the home page and nobody notices anything, until someone asks about the strange ?page= parameter in the address bar.
A Small Experiment
★☆☆ Change the first line of Index.cshtml to @page "{handler?}". How does the action attribute of the rendered delete form change? Try both the old and the new address with curl.
★★☆ Initialize the Catalog property with an empty BookCatalogViewModel instead of null! and send the ?handler=Archive request again. What do you see now instead of a 500? Is that behavior better, or does it hide the error? Change AuditPageFilter so that it returns 404 when no handler is found (assign context.Result inside OnPageHandlerExecutionAsync).
★★★ Add an Authors folder under /Manage and write the author CRUD. This time use a policy with AuthorizeFolder("/Manage", "EditorsOnly") and define the EditorsOnly policy with a claim check. Which status code does a signed-in user without the claim get?
In this post we wrote the server side of our screens with both models. Every page sent to the browser also pulls in CSS (Cascading Style Sheets) and JavaScript files, and we haven't yet talked about what those asp-append-version attributes in _Layout do. In the next post we move on to static assets: MapStaticAssets, fingerprinted URLs, build-time compression, CSS isolation and client library management.