ASP.NET Core is Microsoft's modern web framework. The current major version, .NET 8, runs on Windows, macOS, and Linux, and it is fast — frequently the fastest mainstream framework in independent benchmarks. Razor Pages is the simplest way to use ASP.NET Core: it pairs a C# class with an HTML template, generates URLs, handles forms, and stays out of your way.

This article builds a complete CRUD app — Create, Read, Update, Delete — for a tiny to-do list. By the end you will know the whole Razor Pages flow and be able to build a real back-end for any small project.

What Razor Pages actually is

A Razor Page is a pair of files: an .cshtml template (HTML with C# sprinkled in via the @ symbol) and a .cshtml.cs "page model" class with your logic. Routing is file-based: a page at Pages/Tasks.cshtml is reachable at /Tasks. No controller classes, no route tables, no boilerplate.

If you have used PHP, classic ASP, or Node's templating libraries, the mental model will feel familiar. If you are coming from React or Vue, this is server-rendered HTML — the browser gets a fully formed page on first load, with optional JavaScript for interactivity. That model is back in fashion after a decade of single-page apps.

Setting up the project

From a terminal:

dotnet new webapp -n TasksApp
cd TasksApp
dotnet run

That scaffolds a complete Razor Pages project. Open the URL it prints (usually https://localhost:5001) and you will see the welcome page. You are now running ASP.NET Core 8.

The page model class

Every Razor Page has a "page model" class. Open Pages/Index.cshtml.cs:

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace TasksApp.Pages;

public class IndexModel : PageModel
{
    public string Message { get; set; } = "Hello from Razor Pages!";

    public void OnGet()
    {
        // called on GET /
    }
}

Handler methods named OnGet, OnPost, OnPut, OnDelete run when the browser makes that kind of request. They are the controllers, effectively, just colocated with the page instead of in a separate folder.

The Razor template

The companion Pages/Index.cshtml looks like this:

@page
@model IndexModel
<!doctype html>
<html>
<head><title>Tasks</title></head>
<body>
  <h1>@Model.Message</h1>
</body>
</html>

The @page directive turns the file into a routable page. @model IndexModel gives you strongly-typed access to the page model. Inside the template, @Model.Message outputs the property from the class. The @ symbol is the entire template language: it is C# in HTML.

Handling a form submission

Real apps need to accept user input. Let us add a tiny form to Index.cshtml:

@page
@model IndexModel
<!doctype html>
<html>
<head><title>Tasks</title></head>
<body>
  <h1>Tasks</h1>
  <form method="post">
    <input name="NewTask" />
    <button>Add</button>
  </form>
</body>
</html>

Add an OnPost handler:

public string? NewTask { get; set; }

public IActionResult OnPost()
{
    if (string.IsNullOrWhiteSpace(NewTask))
        return Page(); // re-render with the same model

    Tasks.Add(NewTask);
    return RedirectToPage(); // PRG pattern: GET / again
}

The form field's name attribute matches a property on the page model — that is how data binds. On submit, the OnPost handler runs. The Post-Redirect-Get pattern (return a redirect after a POST) prevents duplicate submissions on refresh.

Storing data with Entity Framework Core

For a real app you need a database. Entity Framework Core is Microsoft's ORM. Add it with:

dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package Microsoft.EntityFrameworkCore.Design

Define a model:

public class TaskItem
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public bool IsDone { get; set; }
}

Add a DbContext:

public class AppDb : DbContext
{
    public AppDb(DbContextOptions<AppDb> options) : base(options) {}
    public DbSet<TaskItem> Tasks => Set<TaskItem>();
}

Register it in Program.cs:

builder.Services.AddDbContext<AppDb>(o =>
    o.UseSqlite("Data Source=tasks.db"));

Create the database:

dotnet ef migrations add Initial
dotnet ef database update

Now your page model can inject the context and query it. See our EF Core Migrations article for the full walkthrough.

Tag helpers: nicer HTML

Razor Pages ships with tag helpers that make forms less repetitive. Instead of writing raw HTML, you can use:

<form method="post">
  <input asp-for="NewTask" />
  <span asp-validation-for="NewTask"></span>
  <button>Add</button>
</form>

The asp-for tag helper generates the right name and id attributes from your model property, and asp-validation-for renders error messages. It is the small quality-of-life feature that, once you have used it, you cannot go back to plain HTML forms.

Validation

Add data annotations to your model:

public class TaskItem
{
    public int Id { get; set; }

    [Required, StringLength(200, MinimumLength = 1)]
    public string Title { get; set; } = "";

    public bool IsDone { get; set; }
}

Add [BindProperty] on the page model property and the validation will run automatically when the form submits. If validation fails, ModelState.IsValid is false and the page re-renders with the error messages. The Microsoft docs on validation cover every annotation option.

Layouts and partial views

Real sites share a header and footer across every page. Razor Pages has layouts for that. Edit Pages/Shared/_Layout.cshtml to add your navigation. Pages opt in by setting Layout = "_Layout" in the _ViewStart.cshtml file (which is the default for new pages).

For repeated chunks of UI, use partial views — files starting with _ — and render them with <partial name="_TaskRow" model="task" />.

Common pitfalls

  • Forgetting @page at the top. Without it, the file is just a partial view, not a routable page.
  • Mixing GET and POST logic. OnGet runs for GET, OnPost for POST. They have different jobs; do not put mutations in OnGet.
  • Forgetting to use RedirectToPage after a POST. Without it, the user sees a "Confirm form resubmission" prompt if they refresh. Always PRG.
  • Putting too much logic in the page model. If your OnPost is 200 lines long, split it into services. The page model should be thin.
  • Not registering services in Program.cs. Every dependency-injected service must be added there first.

Routing and page parameters

Razor Pages uses file-based routing by default, but you can also accept parameters. Add {id:int} to the route:

@page "{id:int}"
@model TasksApp.Pages.EditModel

Then declare the parameter in the page model:

public class EditModel : PageModel
{
    private readonly AppDb _db;
    public EditModel(AppDb db) { _db = db; }
    public TaskItem? Task { get; set; }

    public void OnGet(int id)
    {
        Task = _db.Tasks.Find(id);
    }
}

The framework binds id from the URL to your handler parameter automatically. The same works for query strings: ?id=5 on a GET, or a form field on a POST.

Dependency injection

Anything you want to use inside a page model — your database, an email service, a logger — gets injected via the constructor:

public class IndexModel : PageModel
{
    private readonly AppDb _db;
    private readonly ILogger&lt;IndexModel&gt; _log;

    public IndexModel(AppDb db, ILogger&lt;IndexModel&gt; log)
    {
        _db = db;
        _log = log;
    }
}

ASP.NET Core figures out the dependency graph for you. Register your services in Program.cs with builder.Services.AddScoped<MyService>() or AddSingleton (for shared state) or AddTransient (for fresh instances every time). Use scoped for everything that touches a database.

A more advanced thing: minimal APIs alongside Razor Pages

You will often want a small JSON endpoint — for a fetch call from Vue, for a webhook, for a third-party integration. Razor Pages does not do JSON, but you can add a minimal API in Program.cs:

app.MapGet("/api/tasks", async (AppDb db) =&gt;
    await db.Tasks.ToListAsync());

app.MapPost("/api/tasks", async (TaskItem t, AppDb db) =&gt;
{
    db.Tasks.Add(t);
    await db.SaveChangesAsync();
    return Results.Created($"/api/tasks/{t.Id}", t);
});

Minimal APIs live alongside Razor Pages in the same app. Use Razor for HTML pages, minimal APIs for JSON. We cover this pattern in more detail in our REST API Design article.

Further reading

Razor Pages is a thin layer over MVC built for page-focused scenarios. These are the docs the Mangobaz team consults weekly.

FAQ

Razor Pages or MVC?

Razor Pages for page-focused apps. MVC for large API-heavy apps. Razor Pages is simpler and recommended for new development unless you have a specific reason for MVC.

Razor Pages or Blazor?

Different models. Razor Pages is server-rendered HTML with optional JavaScript. Blazor is a component framework that runs C# in the browser via WebAssembly. Pick Razor Pages for traditional web apps, Blazor for highly interactive single-page apps where you want to stay in C#.

How do I deploy?

Publish to a folder with dotnet publish, then copy the output to any Linux server with the .NET 8 runtime. Azure, AWS, Render, and Fly.io all support .NET out of the box. A Docker image is the easiest portable option.

Can I use Razor Pages with a SPA framework like Vue?

Yes — Razor Pages for the back-end, Vue mounted inside a specific page element. Use Razor for forms, auth, and SEO, and Vue for the highly interactive parts of the UI. Many production apps do exactly this.

What about authentication?

Use ASP.NET Core Identity. Add Microsoft.AspNetCore.Identity.EntityFrameworkCore, scaffold the identity pages, and you get registration, login, password reset, and two-factor authentication for free.

What is the difference between Razor Pages and Blazor?

Razor Pages renders HTML on the server and sends it to the browser. Blazor runs C# code either on the server with a SignalR connection (Blazor Server) or in the browser via WebAssembly (Blazor WebAssembly). Pick Razor Pages for traditional web apps, Blazor for highly interactive single-page experiences.

How do I handle errors?

Add a developer exception page in development (app.UseDeveloperExceptionPage()) and a custom error handler in production. Catch unexpected exceptions in middleware so users never see stack traces.

How do I add HTTP clients to call external APIs?

Use IHttpClientFactory and inject HttpClient into your services. Register named clients with AddHttpClient("github", c => c.BaseAddress = new Uri("https://api.github.com")). Avoid new HttpClient() — it leaks sockets.

You will see most production ASP.NET Core apps use Razor Pages for HTML and minimal APIs for JSON endpoints. They share the same DI container, configuration, and middleware — they are just two different ways to expose endpoints.

Homework

Build the to-do app from this article end-to-end:

  • Create the project with dotnet new webapp.
  • Add a TaskItem model with Title and IsDone.
  • Wire up EF Core with SQLite.
  • Build a list page (/Tasks) that shows all tasks with checkboxes.
  • Build an add form with validation.
  • Build an edit page (/Tasks/Edit/{id}) and a delete handler.
  • Add a layout with a header and footer.

You will have a fully working CRUD app in under two hours. The lessons learned here transfer directly to every ASP.NET Core project you will ever build. For follow-up reading, see JWT Authentication in ASP.NET Core 8.