Razor Pages in ASP.NET Core: What This FAQ Covers
Razor Pages is a page-based way to build server-rendered web apps in ASP.NET
Core. Each page is a .cshtml file for the
markup, paired with a C# class called a PageModel that handles the page's
requests. This Razor Pages ASP.NET Core FAQ answers 27 questions that tend to
show up once you're past "hello world": why a handler isn't called, how to get
a route parameter into a page, and whether a project should use Razor Pages or
MVC. Each answer includes code you can adapt.
MVC (Model-View-Controller) is the pattern where controllers handle many
views. Razor Pages ships as part of ASP.NET Core and organizes the same
machinery around individual pages instead. The examples here work on .NET 8
and later. As of October 2026, .NET 10 is the current LTS (long-term support)
release and the right choice for a new project, and Microsoft's support for
both .NET 8 and .NET 9 ends on November 10, 2026. Project templates change
between versions (newer templates, for example, may call
MapStaticAssets() in
Program.cs), so your generated files may look
slightly different from the snippets.
You'll need basic C# and HTML, the .NET SDK, and a project you can run.
Running dotnet new webapp creates a Razor
Pages project. Some snippets assume an Entity Framework Core
ApplicationDbContext and a
Product class with
Id and
Name properties, so adjust those names to
match your own project.
The questions are grouped into five areas: fundamentals and project structure, handlers and routing, model binding and validation, architecture and comparison with MVC, and security and testing. Skim to what you need or read straight through.
Fundamentals and Project Structure
1. What exactly is a Razor Page, and how is it different from a Razor view?
A Razor view (used in MVC) is just a template file that a controller renders.
It has no code-behind of its own. A Razor Page is a self-contained unit: a
.cshtml file with a
@page directive at the top, usually paired
with a PageModel class in a matching
.cshtml.cs file. The page handles its own
request logic instead of relying on an external controller.
2. What does the @page directive actually do?
It's what turns a .cshtml file into a
routable, independently requestable Razor Page instead of a partial view or
template fragment. @page must be the first
directive in the file. Without it, ASP.NET Core treats the file as an ordinary
Razor view, and it won't be reachable by its own URL.
@page
@model Codingvila.Pages.ContactModel
<h1>Contact Codingvila</h1>
Expected result: browsing to /Contact shows
the heading. Remove @page and the same URL
returns a 404.
3. What's the standard project structure for Razor Pages?
By convention, pages live in a Pages folder
at the project root. A small site with a home page and a contact page looks
like this:
Program.cs
/wwwroot
/Pages
Index.cshtml
Index.cshtml.cs
Contact.cshtml
Contact.cshtml.cs
_ViewImports.cshtml
_ViewStart.cshtml
/Shared
_Layout.cshtml
The wwwroot folder holds static files such as
CSS, JavaScript, and images. Three special files sit in
Pages.
_ViewImports.cshtml holds directives shared
by every page below it, such as @using,
@namespace, and
@addTagHelper.
_ViewStart.cshtml runs before each page and
usually just sets the layout.
_Layout.cshtml is the shared page shell
(header, navigation, footer) that wraps your pages.
Matching file names (Contact.cshtml and
Contact.cshtml.cs) are a convention that
helps tools like Visual Studio nest the two files together and helps humans
find them. They are not what connects the page to its PageModel. That
connection is the @model directive, covered
in question 6. The only wiring in
Program.cs is registering Razor Pages and
mapping its endpoints.
4. How do I register Razor Pages in
Program.cs?
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
var app = builder.Build();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapRazorPages();
app.Run();
AddRazorPages() registers the services needed
for page discovery, model binding, and Tag Helpers (server-side components
that let attributes such as asp-for and
asp-page shape the HTML your page produces).
MapRazorPages() wires up the routing
endpoints based on the file structure under
Pages.
Run dotnet run and open the URL printed in
the console. The home page should load.
5. What is a PageModel, and is it required?
PageModel is a base class your code-behind
class inherits from. It gives you access to things like
ModelState,
Request,
User, and the
Page() and
RedirectToPage() helper methods. Technically
you can write a Razor Page without a PageModel by using
@functions blocks directly in the
.cshtml. In any real project you'll want the
PageModel, because it keeps logic testable and out of the markup.
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Codingvila.Pages;
public class ContactModel : PageModel
{
[BindProperty]
public string Email { get; set; } = string.Empty;
public void OnGet()
{
}
}
The two using lines matter. The default web
project's implicit usings don't include
Microsoft.AspNetCore.Mvc.RazorPages, so
PageModel won't compile without it. The
namespace line matters too, because the type
it produces (Codingvila.Pages.ContactModel)
has to be the one the page's @model directive
resolves to (see question 6). The project templates generate the namespace for
you, so keep it if you copy this class into your own project.
6. How does the PageModel connect to its
.cshtml file?
Through the @model directive, which names the
PageModel's type. That name has to resolve to a real class. You can write it
fully qualified, as below:
@page
@model Codingvila.Pages.ContactModel
Or you can use the short name, which is what the project templates do:
@page
@model ContactModel
The short form works because the template's
_ViewImports.cshtml normally contains an
@namespace line that matches the namespace of
your pages. If you move a page into another folder or rename a namespace, the
short name can stop resolving.
If the name doesn't resolve, or the PageModel class isn't
public, the build fails with an error
pointing at the view. A wrong or missing namespace is the most common cause.
Handlers and Routing
7. What are OnGet and OnPost, and why do they matter?
These are handler methods, the Razor Pages equivalent of controller action
methods. OnGet runs when the page receives an
HTTP GET request, and OnPost runs on POST.
Other verbs follow the same pattern (OnPut,
OnDelete), though browser forms can only send
GET and POST, so those handlers are called from JavaScript. Both
OnGet and
OnPost have asynchronous equivalents,
OnGetAsync and
OnPostAsync, which you should prefer whenever
the handler does I/O such as a database call. Pick one or the other for each
verb: a page can't have both OnGet and
OnGetAsync, because the framework treats them
as the same handler. A handler can return
void,
IActionResult, or the
Task versions of those.
The example below receives its database context through the constructor. This
is dependency injection: ASP.NET Core creates the context and passes it in, as
long as you registered it in Program.cs with
AddDbContext (for example with
UseSqlServer or
UseSqlite). The
AsNoTracking() call tells Entity Framework
Core not to track these rows for changes, which saves work when the page only
displays them.
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
public class ProductsModel : PageModel
{
private readonly ApplicationDbContext _context;
public ProductsModel(ApplicationDbContext context)
{
_context = context;
}
public List<Product> Products { get; set; } = new();
public async Task OnGetAsync()
{
Products = await _context.Products
.AsNoTracking()
.ToListAsync();
}
}
The diagram below shows a simplified view of what happens to a request before and after your handler runs. Routing picks the page, the framework picks a handler by HTTP method and optional handler name, model binding fills your properties and parameters, and then your code runs and returns a result.
NotFound() or JSON.8. How do named handlers (OnPostDelete, OnPostPublish) work, and when should I use them?
A single page can have multiple POST handlers distinguished by a suffix after
OnPost, for example
OnPostDeleteAsync. The handler name is what's
left after you remove OnPost and
Async. You select which one runs by adding
asp-page-handler="Delete" to the form. The
Tag Helper puts a handler value in the
request URL (for example ?handler=Delete),
and the framework matches it to the method. This matters once a page has more
than one meaningful action, like a product edit page with both "Save" and
"Delete" buttons, without splitting it into two pages.
Start with the page's markup. The route template
{id:int} makes the product's
Id part of the URL, so
/EditProduct/42 edits product 42.
@page "{id:int}"
@model Codingvila.Pages.EditProductModel
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace Codingvila.Pages;
public class EditProductModel : PageModel
{
private readonly ApplicationDbContext _context;
public EditProductModel(ApplicationDbContext context)
{
_context = context;
}
[BindProperty]
public Product Product { get; set; } = new();
public async Task<IActionResult> OnGetAsync(int id)
{
var product = await _context.Products.FindAsync(id);
if (product is null)
{
return NotFound();
}
Product = product;
return Page();
}
public async Task<IActionResult> OnPostSaveAsync()
{
if (!ModelState.IsValid)
{
return Page();
}
_context.Attach(Product).State = EntityState.Modified;
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}
public async Task<IActionResult> OnPostDeleteAsync(int id)
{
var product = await _context.Products.FindAsync(id);
if (product is not null)
{
_context.Products.Remove(product);
await _context.SaveChangesAsync();
}
return RedirectToPage("./Index");
}
}
<form method="post" asp-page-handler="Save">
<input type="hidden" asp-for="Product.Id" />
<input asp-for="Product.Name" />
<button type="submit">Save</button>
</form>
<form method="post" asp-page-handler="Delete" asp-route-id="@Model.Product.Id">
<button type="submit">Delete</button>
</form>
Expected result: opening
/EditProduct/42 loads the product into the
form. Clicking "Save" runs OnPostSaveAsync.
Clicking "Delete" posts to
/EditProduct/42?handler=Delete, runs
OnPostDeleteAsync, and redirects to the index
page. The hidden Product.Id field matters:
without it, the Save handler receives a product with
Id 0 and can't update the right row.
A common mistake is leaving off
asp-page-handler. The request then goes to
the plain OnPost or
OnPostAsync method if one exists. If it
doesn't, the exact outcome depends on your version and setup, so test it and
check the ASP.NET Core docs for your version instead of assuming a specific
HTTP status code.
One more caution: binding a whole entity like
Product with
[BindProperty] is fine for a demo, but it
lets a client submit values for every property on that entity (over-posting).
In production, bind a small input class that holds only the fields the form
should change, or call
TryUpdateModelAsync with an explicit list of
properties. Also remember that a Delete handler like this one lets anyone who
can reach the page delete any row, so protect it with authorization (questions
13 and 26).
9. What's the default routing convention for Razor Pages?
The route is derived from the file's path relative to the
Pages folder.
Pages/Products/Edit.cshtml maps to
/Products/Edit, and
Pages/Admin/Users/Index.cshtml maps to
/Admin/Users. A file named
Index.cshtml is treated specially: its
segment can be dropped from the route, the same way MVC treats an
Index action. So
Pages/Index.cshtml answers at
/ (and also at
/Index).
10. Can I customize the route for a specific page?
Yes, by adding a route template directly to the
@page directive. This is the Razor Pages
equivalent of attribute routing on a controller action. A template that starts
with / replaces the file-based route
entirely. A template without the leading slash is appended to the page's
file-based route.
@page "/catalog/{category}/{id:int}"
@model Codingvila.Pages.Catalog.DetailsModel
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Codingvila.Pages.Catalog;
public class DetailsModel : PageModel
{
public void OnGet(string category, int id)
{
// category and id are bound from the route
}
}
The {id:int} segment uses a route constraint,
which restricts that segment to integers. If a non-numeric value is supplied,
the route doesn't match and the request returns a 404. This is the same
constraint syntax used in MVC attribute routing.
Expected result: /catalog/shoes/42 reaches
OnGet with
category set to
shoes and
id set to
42, while
/catalog/shoes/abc returns a 404. Don't try
to set routes with attributes like
[HttpGet("/some-url")] on handler methods.
Handlers are chosen after routing finishes, so routes belong in
@page or in conventions.
11. How do route parameters get into my handler method?
ASP.NET Core model binding matches route values, query string values, and form
values to parameter names on your handler method, or to properties on your
PageModel marked with [BindProperty]. Names
are matched case-insensitively, but otherwise they must match exactly. For
simple GET scenarios, method parameters are usually cleaner than properties.
If the @page template has no
{id} segment,
id is read from the query string instead
(?id=7).
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
public class ProductDetailsModel : PageModel
{
// _context is injected through the constructor, as in question 7
public Product? Product { get; set; }
public async Task<IActionResult> OnGetAsync(int id)
{
Product = await _context.Products.FindAsync(id);
if (Product is null)
{
return NotFound();
}
return Page();
}
}
12. What's the difference between
[BindProperty] and a plain method parameter?
A method parameter is bound only for that specific handler call and discarded
afterward. A property marked with
[BindProperty] is bound automatically on
non-GET requests such as POST (by default) and is accessible throughout the
PageModel instance, including from the Razor markup itself. If you need the
value on GET too, you must explicitly set
SupportsGet = true. To bind every public
property on a PageModel instead of marking them one by one, put
[BindProperties] on the class (it accepts
SupportsGet as well), but be sure you really
want all of them exposed to the client.
[BindProperty(SupportsGet = true)]
public string? SearchTerm { get; set; }
public void OnGet()
{
// SearchTerm is populated from the query string here
}
Leaving SupportsGet off and expecting a
[BindProperty] value to populate on a GET
request is one of the more common sources of "why is this null" bugs in Razor
Pages.
13. How do folders affect routes, and how do I apply rules to a whole folder?
Nested folders under Pages become nested URL
segments automatically, with no extra configuration. To apply a rule to every
page in a folder, such as requiring authorization, or to give a page an extra
URL, use conventions in
AddRazorPages(options => options.Conventions...)
instead of editing each page.
builder.Services.AddRazorPages(options =>
{
options.Conventions.AuthorizeFolder("/Admin");
options.Conventions.AddPageRoute("/Catalog/Index", "/shop");
});
AuthorizeFolder applies an authorization
requirement to every page under that folder without decorating each PageModel
individually. AddPageRoute adds an additional
route to an existing page without moving its file. The original file-based
route still works, but links that ASP.NET Core generates for that page (for
example with asp-page) will use
/shop.
Note that AuthorizeFolder only does something
useful once authentication is set up in your app (for example, ASP.NET Core
Identity plus app.UseAuthentication() before
app.UseAuthorization()). Without it, a
request to a protected page typically fails at runtime with an error about a
missing authentication scheme instead of sending the visitor to a login page.
The Program.cs in question 4 doesn't include
authentication.
14. Does Razor Pages support Areas?
Yes. Areas partition a large app into feature modules, each with its own
Pages, Controllers, and Views folders. They work with Razor Pages the same way
they do with MVC, under an
Areas/{AreaName}/Pages folder structure. You
reference them with asp-area in Tag Helpers,
or with an area route value in
RedirectToPage calls.
<a asp-area="Admin" asp-page="/Dashboard/Index">Admin Dashboard</a>
Areas are worth it once a Razor Pages app grows past a handful of features,
since they keep related pages grouped instead of one sprawling
Pages folder.
Model Binding and Validation
15. How do data annotations work for validation in Razor Pages?
The same
System.ComponentModel.DataAnnotations
attributes used in MVC work directly on PageModel properties bound with
[BindProperty].
ModelState.IsValid reflects the result after
model binding runs. With nullable reference types enabled, a non-nullable
string property is also treated as required
by default.
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
public class RegisterModel : PageModel
{
[BindProperty]
[Required]
[EmailAddress]
public string Email { get; set; } = string.Empty;
[BindProperty]
[Required]
[StringLength(100, MinimumLength = 8)]
public string Password { get; set; } = string.Empty;
public IActionResult OnPost()
{
if (!ModelState.IsValid)
{
return Page();
}
// registration logic
return RedirectToPage("./Confirmation");
}
}
This example only demonstrates validation. In a real app, never store the password as plain text. A library such as ASP.NET Core Identity handles hashing for you.
16. How do I display validation errors in the view?
Use the asp-validation-for and
asp-validation-summary Tag Helpers, and
include the client-side validation scripts through the page's
Scripts section. The default layout renders
that section, and the project template provides the
_ValidationScriptsPartial file. The summary
below uses ModelOnly, so it shows only errors
that don't belong to a single field. With
All, every field error would appear twice:
once under its field and again in the summary.
<form method="post">
<label asp-for="Email"></label>
<input asp-for="Email" class="form-control" />
<span asp-validation-for="Email" class="text-danger"></span>
<label asp-for="Password"></label>
<input asp-for="Password" type="password" class="form-control" />
<span asp-validation-for="Password" class="text-danger"></span>
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<button type="submit">Register</button>
</form>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
Expected result: submitting the form with an empty email shows the error message under the field without a full page reload. If the scripts aren't loaded, the same message appears after the server round trip instead.
17. How do client-side and server-side validation work together?
The jQuery Validation scripts included via
_ValidationScriptsPartial read the same data
annotation metadata, which the Tag Helpers render into
data-val-* HTML attributes. Client-side
checks run first for instant feedback. Server-side validation through
ModelState.IsValid always runs regardless,
because client-side checks can be bypassed with disabled JavaScript or a
hand-built request sent from a tool like curl or Postman. Treat client-side
validation as a usability layer on top of the server-side check, never a
replacement for it.
18. Can I write a custom validation attribute?
Yes, by inheriting from
ValidationAttribute and overriding
IsValid. This works identically in Razor
Pages and MVC since it's part of the shared data annotations infrastructure
(you need the same
using System.ComponentModel.DataAnnotations;
line as in question 15).
public class NoBlockedWordsAttribute : ValidationAttribute
{
private static readonly string[] BlockedWords = { "spam", "scam" };
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
if (value is string text &&
BlockedWords.Any(word => text.Contains(word, StringComparison.OrdinalIgnoreCase)))
{
return new ValidationResult("This field contains disallowed content.");
}
return ValidationResult.Success;
}
}
Apply it to a property the same way as a built-in attribute:
[NoBlockedWords] above a
Comment property, for example. A custom
attribute like this runs on the server only. Getting the same check in the
browser takes extra client-side work, so keep relying on
ModelState.IsValid. Also note that
Contains matches substrings, so this filter
will flag any word that merely includes a blocked string. Real content filters
need more care than this demo.
19. How do I validate and submit a form with AJAX instead of a full page post?
You still define a handler, usually a named POST handler that returns JSON
instead of Page(), and submit with
fetch or a similar client-side call. The name
matters: ?handler=Comment targets a method
named OnPostCommentAsync. Because you're
building the request yourself, you also have to send the anti-forgery token
yourself. The page needs a hidden token field to read it from. A form that
uses the form Tag Helper adds one, or you can call
@Html.AntiForgeryToken() in the markup.
Send the token in a request header. The default header name is
RequestVerificationToken in current ASP.NET
Core versions, which is what the example below uses. You only need to set
AntiforgeryOptions.HeaderName in
Program.cs if you want a different name.
public async Task<IActionResult> OnPostCommentAsync([FromBody] CommentDto dto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await _commentService.AddAsync(dto);
return new JsonResult(new { success = true });
}
const token = document.querySelector('input[name="__RequestVerificationToken"]').value;
async function submitComment(dto) {
const response = await fetch('/Articles/Details?handler=Comment', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': token
},
body: JSON.stringify(dto)
});
if (!response.ok) {
const errors = await response.json();
console.error(errors);
return;
}
const result = await response.json();
}
The _commentService field is assumed to be
injected through the constructor, and
/Articles/Details stands in for your own page
URL. The script must run after the token field exists in the page. If the
field is missing, querySelector returns
null and the script throws. If the token is
missing or wrong, the server rejects the request, typically with a 400. The
?handler=Comment query string is how you
target a named handler from a raw HTTP request. The
asp-page-handler Tag Helper does the same
job, but only in markup rendered on the server.
20. What is ViewData, and how is it different from PageModel properties?
ViewData is a loosely typed dictionary (IDictionary<string, object>, essentially) for passing small, incidental bits of data from a handler to
the page, most often things like a page title. PageModel properties are
strongly typed and are the preferred way to pass your actual data model to the
view, since they give you compile-time checking and IntelliSense that
ViewData can't. In practice, reach for
ViewData["Title"] for layout-level metadata
and use PageModel properties for everything that's part of the page's data.
public void OnGet()
{
ViewData["Title"] = "Product Catalog — Codingvila";
Products = GetProducts();
}
Here GetProducts() stands in for whatever
method loads your data.
Razor Pages vs MVC: Architecture and When to Choose Which
21. What's the real difference between Razor Pages and MVC, beyond file layout?
MVC centers on controllers, where a single controller class often handles several related actions and returns different views. Razor Pages centers on the page itself. Each page is its own small unit with its own handlers, which keeps page-specific logic from leaking into a shared controller that handles five unrelated views. Functionally they sit on the same foundation: both use the same routing engine, the same model binding, the same Tag Helpers, and the same validation pipeline. The difference is organizational, not a difference in underlying capability.
| Aspect | Razor Pages | MVC |
|---|---|---|
| Basic unit |
A page: .cshtml file plus
PageModel
|
A controller with actions, plus separate views |
| Request code |
Handler methods such as OnGet and
OnPostAsync
|
Action methods on a controller |
| Default routing |
Based on the file path under Pages,
plus an optional @page template
|
Conventional routes or attribute routing |
| Shared foundation | Same model binding, Tag Helpers, and validation | |
22. When should I choose Razor Pages over MVC for a new project?
Razor Pages tends to fit content-and-forms-driven applications well: admin panels, internal tools, CRUD-heavy (create/read/update/delete) line-of-business apps, and sites where most pages map cleanly to one URL and one primary action. For many internal business apps, Razor Pages ends up with a flatter, easier-to-navigate codebase than MVC, because you're not hunting through a controller with a dozen actions to find the one tied to a specific view. That is a common view among developers rather than a measured result, so weigh it against your team's habits.
MVC tends to make more sense when you have a genuinely shared-logic surface: multiple views rendered by overlapping logic, complex action filters applied across many endpoints, or a team that's standardized on it for consistency with existing controllers. If the app is mostly an API with little to no server-rendered HTML, use minimal APIs or controller-based Web API instead of either UI approach. If you need a highly interactive UI, Blazor is Microsoft's component-based alternative, and it's a separate topic from this FAQ.
23. Can Razor Pages and MVC controllers coexist in the same ASP.NET Core project?
Yes, and this is more common than people expect. Register both sets of
services and map both sets of endpoints in the same
Program.cs. Use
AddControllers() if your controllers only
return data such as JSON, or
AddControllersWithViews() if they also render
Razor views. A typical real-world case is a Razor Pages site for the
user-facing UI with a handful of controllers or minimal API endpoints exposing
JSON for AJAX calls or a mobile client.
builder.Services.AddRazorPages();
builder.Services.AddControllers();
var app = builder.Build();
app.MapRazorPages();
app.MapControllers();
24. How do I migrate an existing MVC view/controller pair to a Razor Page?
Don't expect a one-click converter. Plan on doing it by hand, though the work
is fairly mechanical. Move the markup from the
.cshtml view into a new file under
Pages, add the
@page directive at the top, and move the
controller action's logic into OnGet and
OnPost handler methods on a new PageModel
class. return View() becomes
return Page(), and
RedirectToAction becomes
RedirectToPage. Links that used
asp-controller and
asp-action need to switch to
asp-page.
In the markup, Model now refers to the
PageModel itself. If your MVC view used
@Model.Name on a view model, expose that data
as a property such as Product and write
@Model.Product.Name instead.
Pay close attention to any [HttpPost],
[ValidateAntiForgeryToken], or model binding
attributes on the original action. They map to handler naming conventions,
automatic anti-forgery validation, and
[BindProperty] in Razor Pages rather than
being copied verbatim, since the mechanisms are similar but not identical in
syntax.
Security and Testing
25. How do anti-forgery tokens work in Razor Pages?
Anti-forgery protection is a defense against cross-site request forgery (CSRF)
attacks, where a malicious site tricks a browser into submitting a request on
your behalf. In Razor Pages, the form Tag Helper adds the token to forms that
use <form method="post">, and token
validation is applied automatically to POST and other non-GET requests. You
don't need to add
[ValidateAntiForgeryToken] manually the way
you often do in MVC. The flip side is that a hand-built form or AJAX call that
doesn't send the token will be rejected (see question 19 for the AJAX
version).
One catch: the Tag Helper skips the hidden token field if you set a plain
action attribute on the form yourself. Let
asp-page or
asp-page-handler build the URL when you can,
or add asp-antiforgery="true" to the form to
force the token.
26. How do I require authorization on a Razor Page?
You can decorate a PageModel class with
[Authorize] (from the
Microsoft.AspNetCore.Authorization namespace)
just like a controller, or apply it at the folder level as shown in question
13 with AuthorizeFolder. Put the attribute on
the class, not on individual handler methods, because Razor Pages doesn't
support it on handlers. Folder-level authorization is usually the better
choice for an admin section, since it's one line instead of repeating the
attribute on every page.
[Authorize(Roles = "Admin")]
public class DeleteUserModel : PageModel
{
// _userService is injected through the constructor (omitted here)
public async Task<IActionResult> OnPostAsync(int id)
{
await _userService.DeleteAsync(id);
return RedirectToPage("./Index");
}
}
Authorization decides who can reach a page. It doesn't decide which records they may touch, so if users should only edit their own data, check ownership inside the handler as well.
27. How do I unit test a PageModel?
PageModel classes are ordinary C# classes and can be unit tested directly
without spinning up a web server, as long as you keep business logic out of
the markup and inject dependencies through the constructor so they can be
mocked. The example below tests the
RegisterModel from question 15. Depending on
which members your handler touches, a PageModel may need a
PageContext outside a real request, so this
example sets one up.
// Needs the xUnit package plus these usings: Xunit, Microsoft.AspNetCore.Http,
// Microsoft.AspNetCore.Mvc, Microsoft.AspNetCore.Mvc.ModelBinding,
// Microsoft.AspNetCore.Mvc.RazorPages, Microsoft.AspNetCore.Mvc.ViewFeatures,
// and Microsoft.AspNetCore.Routing.
[Fact]
public void OnPost_WithInvalidModel_ReturnsPage()
{
var modelState = new ModelStateDictionary();
var actionContext = new ActionContext(
new DefaultHttpContext(),
new RouteData(),
new PageActionDescriptor(),
modelState);
var viewData = new ViewDataDictionary(new EmptyModelMetadataProvider(), modelState);
var model = new RegisterModel
{
PageContext = new PageContext(actionContext) { ViewData = viewData },
Email = string.Empty,
Password = "short"
};
// Model binding doesn't run in a unit test, so add the error yourself.
model.ModelState.AddModelError("Email", "Email is required");
var result = model.OnPost();
Assert.IsType<PageResult>(result);
}
One limitation worth flagging: constructing a
PageModel directly in a unit test bypasses
the real model binding and routing pipeline, so this kind of test verifies
your handler's logic, not the full HTTP request/response cycle. For that level
of coverage, an integration test using
WebApplicationFactory is the more reliable
approach, though that's a broader topic than this FAQ can cover in full.
Common Errors and How to Fix Them
-
The page returns a 404. Check that
@pageis the first directive, that the file is underPages, and that any route constraint (like{id:int}) matches the URL you're requesting. -
A
[BindProperty]value is null on GET. AddSupportsGet = true(question 12). - A POST returns a 400. The anti-forgery token is probably missing. Use the form Tag Helper, or send the token in a header for AJAX calls (questions 19 and 25).
-
The wrong handler runs, or none does. The handler name is
the method name minus
OnPostandAsync, and it must matchasp-page-handleror?handler=(question 8). -
An error about ambiguous or multiple handlers. A page can't
define both
OnGetandOnGetAsync(or bothOnPostandOnPostAsync). Keep one of each (question 7). -
A build error mentions
@model. Make sure the PageModel class ispublicand that the name in@modelresolves, either fully qualified or through the namespace in_ViewImports.cshtml(question 6). -
The app fails to start with a missing-service error. A
constructor parameter such as
ApplicationDbContextmust be registered inProgram.csbeforebuilder.Build()(question 7). -
A protected page throws an authentication-scheme error instead of showing
a login page.
AuthorizeFolderor[Authorize]is in place, but authentication isn't configured (questions 13 and 26). -
A route attribute on a handler is ignored or flagged. Set
routes with
@pageor conventions, not attributes like[HttpGet("/url")](question 10).
Summary
Razor Pages in ASP.NET Core gives you the same routing, model binding, and
validation pipeline as MVC, organized around individual pages instead of
shared controllers. Once you know three things, most of the "why isn't this
working" confusion goes away. The first is that
@model is what links a page to its PageModel.
The second is how handler names are matched. The third is that
[BindProperty] only populates on non-GET
requests unless you opt in. Add the anti-forgery rules, folder-level
authorization, and a clear sense of when MVC fits better, and you can make
most architecture decisions with confidence. For a next step, build a small
CRUD feature end to end (list, create, edit, delete) using named handlers on a
single page. That one exercise runs into most of the routing and binding edge
cases covered here.
