Routing
A clinic app may have a hundred URLs: /doctors, /doctors/7, /slots/2026-11-20, /admin/reports/2026/nov. Routing is the part of ASP.NET Core that looks at each incoming request and decides which piece of code should handle it. It also works in the other direction: given an endpoint and some values, it can build the URL for you.
In this lesson you will write route templates with parameters, defaults and constraints, learn how ASP.NET Core picks between similar routes, group related endpoints, and generate links instead of hard-coding them.
Endpoint routing in two steps
Routing in ASP.NET Core is called endpoint routing, and it works in two steps:
- Matching (
UseRouting): early in the pipeline, the request path and method are compared with every endpoint's route template, and the best match is stored on theHttpContext. - Executing (
UseEndpoints): at the end of the pipeline, the chosen endpoint runs.
WebApplication adds both steps for you. Because matching happens early, middleware in between (such as authorization or rate limiting) already knows which endpoint will run and can read its metadata, for example "this endpoint requires a logged-in user".
Minimal APIs, controllers, Razor Pages, Blazor and SignalR hubs are all endpoints in the same routing system, so the rules in this lesson apply everywhere.
Route templates and parameters
A route template is a pattern. Plain text such as doctors must match exactly (ignoring case); text in curly braces is a route parameter that captures part of the URL.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/doctors", () => "All doctors");
app.MapGet("/doctors/{id:int}", (int id) => $"Doctor number {id}");
app.MapGet("/doctors/{name}", (string name) => $"Doctor profile: {name}");
app.MapGet("/slots/{date}/{shift=morning}", (DateOnly date, string shift) =>
$"{shift} slots on {date:dd MMM yyyy}");
app.MapGet("/files/{**path}", (string path) => $"File requested: {path}");
app.Run();Requests and responses:
GET /doctors -> All doctors GET /doctors/7 -> Doctor number 7 GET /doctors/meera -> Doctor profile: meera GET /slots/2026-11-20 -> morning slots on 20 Nov 2026 GET /slots/2026-11-20/evening -> evening slots on 20 Nov 2026 GET /files/reports/2026/nov.pdf -> File requested: reports/2026/nov.pdf
What each template feature does:
{id:int}captures a value and adds a constraint: it only matches whole numbers.{shift=morning}has a default value used when the segment is missing.{name?}(not shown) would be optional: the parameter isnullwhen missing.{**path}is a catch-all parameter: it captures the rest of the path, slashes included. Use{*path}to capture the same way; the two differ only when generating links (**keeps slashes unencoded).
The route value 2026-11-20 is converted to a DateOnly automatically. If the text cannot be converted, for example /slots/tomorrow, binding fails before your code runs: in Production the client gets 400 Bad Request, and in Development you see an exception page saying the app failed to bind parameter DateOnly date from "tomorrow".
Constraints
Constraints narrow down what a parameter accepts. The most useful ones:
| Constraint | Example | Matches |
|---|---|---|
int, long | {id:int} | Whole numbers |
bool | {active:bool} | true or false |
guid | {id:guid} | GUID values |
alpha | {city:alpha} | Letters a-z only |
minlength(n), maxlength(n), length(a,b) | {pin:length(6)} | Strings of that length |
min(n), max(n), range(a,b) | {age:range(1,120)} | Numbers in range |
regex(...) | {code:regex(^\d{{3}}$)} | Text matching a regular expression |
Several constraints can be chained: {id:int:min(1)}.
Which route wins? Precedence
In the first example, /doctors/7 matches both /doctors/{id:int} and /doctors/{name}. ASP.NET Core does not use the order you wrote them in; it uses route precedence, which prefers the more specific template:
- More segments beat fewer segments.
- A literal segment (
/doctors/today) beats a parameter (/doctors/{name}). - A parameter with a constraint (
{id:int}) beats one without ({name}). - A catch-all is the least specific of all.
So /doctors/7 goes to the int endpoint and /doctors/meera goes to the name endpoint. If two templates are equally specific and both match, the request fails with an AmbiguousMatchException ("The request matched multiple endpoints"). Fix it by making one template more specific, not by reordering lines.
Route groups
Endpoints for one area of the app usually share a prefix and often the same rules. MapGroup collects them:
var doctors = app.MapGroup("/api/doctors")
.WithTags("Doctors");
doctors.MapGet("/", () => "List doctors");
doctors.MapGet("/{id:int}", (int id) => $"Doctor {id}");
doctors.MapPost("/", () => Results.Created("/api/doctors/3", new { id = 3 }));
var admin = app.MapGroup("/api/admin")
.RequireAuthorization(); // every endpoint inside needs a logged-in user
admin.MapGet("/reports", () => "Monthly report");/api/doctors/7 reaches the second endpoint. Anything added to a group, such as tags for documentation or an authorization rule, applies to every endpoint in it. Groups can be nested, so /api/admin/doctors could be a group inside admin.
Naming routes and generating links
Hard-coding URLs such as "/api/doctors/" + id breaks the day someone renames the route. Instead, give the endpoint a name and let routing build the URL:
app.MapGet("/api/doctors/{id:int}", (int id) => $"Doctor {id}")
.WithName("GetDoctor");
app.MapPost("/api/doctors", (LinkGenerator links) =>
{
int newId = 3; // in a real app, the database creates this
string? url = links.GetPathByName("GetDoctor", new { id = newId });
return Results.Created(url, new { id = newId });
});LinkGenerator is a service that knows every endpoint's template. Here url becomes /api/doctors/3, and the response is 201 Created with a Location: /api/doctors/3 header. Results.CreatedAtRoute("GetDoctor", new { id = newId }, value) does the same in one call.
Routing in controllers and Razor Pages
You will use the same templates in other styles of app:
- Controllers use attributes:
[Route("api/[controller]")]on the class and[HttpGet("{id:int}")]on a method.[controller]is replaced by the controller name without theControllersuffix. - MVC with views often uses one conventional route:
app.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}"), so/Doctors/Details/7calls theDetailsaction ofDoctorsControllerwithid = 7. - Razor Pages take the route from the file path (
Pages/Doctors/Index.cshtmlanswers/Doctors), and a page can add parameters with@page "{id:int}".
Common mistakes
- Using constraints as validation. Bad input should get a 400 with a message, not a 404.
- Two templates that are equally specific.
/doctors/{name}and/doctors/{city}always clash; one URL cannot mean both. - Hard-coding URLs in code and pages. Use named routes,
LinkGeneratoror tag helpers such asasp-pageso links follow route changes. - Forgetting a leading segment in groups.
app.MapGroup("api")and"/api"both work, but be consistent so URLs are easy to read.
Key points
- Endpoint routing matches the request early (
UseRouting) and runs the endpoint at the end (UseEndpoints);WebApplicationadds both. - Route templates use literals,
{parameters}, defaults (=), optional parameters (?) and catch-alls (*,**). - Constraints such as
int,guid,alphaandrangedisambiguate routes; they are not input validation. - Precedence picks the most specific template: literals beat parameters, constrained parameters beat plain ones.
MapGroupshares a prefix and rules;WithNameplusLinkGeneratorbuilds URLs instead of hard-coding them.
Exercise
A railway enquiry API in Jaipur needs these endpoints: GET /trains/{number} where the number is exactly 5 digits (for example 12955), GET /trains/{number}/status/{date} with an optional date that defaults to today, and GET /stations/{code} where the station code is 2 to 5 letters (JP, NDLS). Put the train endpoints in a route group and name the first one GetTrain.
Show solution
var trains = app.MapGroup("/trains");
trains.MapGet("/{number:int:length(5)}", (int number) => $"Train {number}")
.WithName("GetTrain");
trains.MapGet("/{number:int:length(5)}/status/{date?}", (int number, DateOnly? date) =>
{
DateOnly day = date ?? DateOnly.FromDateTime(DateTime.Today);
return $"Running status of {number} on {day:dd MMM yyyy}";
});
app.MapGet("/stations/{code:alpha:length(2,5)}", (string code) =>
$"Station {code.ToUpperInvariant()}");length(5) checks the number of characters in the route value, so int:length(5) accepts exactly five digits. The optional {date?} maps to a nullable DateOnly?, and the handler falls back to today. alpha:length(2,5) accepts only 2 to 5 letters, so /stations/12 does not match.
Quick Quiz
1. Which template matches /doctors/7 when both /doctors/{id:int} and /doctors/{name} exist?
Route precedence prefers parameters with constraints over plain parameters.
2. What does {**path} capture in /files/{**path} for the URL /files/a/b/c.txt?
A catch-all parameter captures the rest of the path, including slashes.
3. Why should you not use {age:range(1,120)} to validate user input?
Constraints decide whether a route matches; a failed match is a 404 with no explanation.
4. What does LinkGenerator.GetPathByName("GetDoctor", new { id = 3 }) return for the route /api/doctors/{id:int}?
The link generator fills the named route's template with the given values.
Learn ASP.NET Core with a mentor
Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
