Routing decides which controller action answers each URL. ASP.NET Core gives you two styles that work together: conventional routing — one pattern that maps most page URLs — and attribute routing — routes declared on the actions themselves, the standard for APIs. This tutorial covers both, plus route parameters, constraints, defaults, and how to generate URLs instead of hard-coding them.

Conventional routing
The MVC template registers a single pattern in Program.cs:
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
Reading the template:
{controller=Home}— first URL segment picks the controller; missing →HomeController.{action=Index}— second segment picks the action; missing →Index.{id?}— optional third segment, bound to anidparameter when present.
Verified against a running app:
| URL | Executes |
|---|---|
/ |
HomeController.Index() |
/Products |
ProductsController.Index() |
/demo/asjson |
DemoController.AsJson() (case-insensitive) |
/demo/route-bind/xyz |
404 — constraint rejects it (below) |
One pattern covers every page controller you'll ever add — that's the appeal for MVC sites.
Attribute routing
Routes declared where the code lives. This is the default style for APIs:
[Route("api/[controller]")]
[ApiController]
public class SuppliersController : ControllerBase
{
[HttpGet] // GET /api/suppliers
public IEnumerable<Supplier> GetAll() => ...;
[HttpGet("{id:int}")] // GET /api/suppliers/5
public ActionResult<Supplier> Get(int id) => ...;
[HttpPost] // POST /api/suppliers
public ActionResult<Supplier> Create(Supplier supplier) => ...;
}
[Route("api/[controller]")]— the[controller]token becomes the class name minusController(suppliers), so renames stay consistent.[HttpGet("{id:int}")]— the HTTP method attribute carries the route template; method + template together select the action.
Attribute routes and the conventional pattern coexist in one app — pages use the default pattern, API controllers use attributes.
Route parameters and constraints
Constraints filter URLs before the action runs:
[Route("demo/route-bind/{id:int}")]
public IActionResult RouteBind(int id) => Json(new { boundFromRoute = id });
GET /demo/route-bind/42 → {"boundFromRoute":42}
GET /demo/route-bind/xyz → 404 (never reaches the action)
Useful constraints: :int, :long, :guid, :bool, :datetime, :min(1), :length(3,50), :alpha, :regex(...). A constrained route documents itself and saves you defensive parsing — the bound id in the action is already a valid int (see model binding for what happens after routing hands over the values).
Catch-all parameters swallow the rest of the path — this platform serves uploaded images with one:
[HttpGet("/blog-media/{**path}")] // matches /blog-media/a/b/c.png, path = "a/b/c.png"
Generating URLs — never hard-code
Hard-coded <a href="/products/5"> breaks the day a route changes. Generate from the route instead:
<a asp-controller="Products" asp-action="Details" asp-route-id="5">Details</a>
return RedirectToAction("Details", "Products", new { id = 5 });
var url = Url.Action("Details", "Products", new { id = 5 });
All three produce /Products/Details/5 today and whatever the route says tomorrow.
How a request finds its action
- Routing middleware (
UseRouting, implicit in minimal hosting) matches the URL against endpoints and picks the best candidate. - Route values (
controller,action,id, …) go into the request. - Middleware between routing and the endpoint (authorization, CORS) runs with full knowledge of the target.
- The action executes with route values model-bound into parameters — or, if nothing matched, the request falls through to a 404.
Areas — routing for large apps
Areas partition a big MVC app into sections (Admin, Store, Support), each with its own controllers and views. The route pattern gains one token:
app.MapControllerRoute(
name: "areas",
pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}");
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
[Area("Admin")]
public class DashboardController : Controller { … } // answers /Admin/Dashboard
{area:exists} only matches registered areas, so ordinary URLs fall through to the default route. Order matters here — the area route must be registered first, because routes are matched in registration order and the default pattern would happily swallow /Admin/Dashboard as controller Admin, action Dashboard.
Multiple routes to one action, and named routes
An action can answer several URLs — useful when supporting a legacy path:
[Route("products/{id:int}")]
[Route("catalog/item/{id:int}", Name = "LegacyProduct")] // old URL, still served
public IActionResult Details(int id) => …;
The Name gives you an unambiguous handle for URL generation (Url.RouteUrl("LegacyProduct", new { id })) no matter how many templates exist. For genuinely retired URLs, prefer a 301 redirect over serving duplicate content — search engines penalize two URLs with the same page.
SEO-friendly URLs: slugs
Id-plus-slug URLs read well and stay stable when titles change:
[Route("blog/{id:int}/{slug?}")]
public IActionResult Post(int id, string? slug)
{
var post = repository.Find(id);
if (post is null) return NotFound();
if (!string.Equals(slug, post.Slug, StringComparison.Ordinal))
return RedirectToActionPermanent(nameof(Post), new { id, slug = post.Slug });
return View(post);
}
The id does the lookup; the slug is cosmetic but enforced via 301 so every post has exactly one canonical URL. (This platform uses pure-slug routes instead — /blog/{slug} — trading a lookup by string for prettier URLs; both are legitimate.)
Debugging routing
When a URL 404s and you're sure the action exists:
- List every endpoint the app knows: inject
EndpointDataSourceand dumpEndpoints— the fastest way to see what a template actually registered. - Check constraint failures —
/demo/route-bind/xyz404s by design; the log atDebuglevel forMicrosoft.AspNetCore.Routingshows candidates considered and rejected. - Ambiguous matches throw
AmbiguousMatchExceptionat request time, not startup — two attribute routes with identical templates on different actions is the usual cause.
Practical guidance
- Pages → conventional route; APIs → attribute routes. Don't mix styles on one controller.
- Constrain every id-like parameter (
{id:int}) — free validation and cleaner 404 behavior. - Lowercase URLs are nicer for SEO:
builder.Services.AddRouting(o => o.LowercaseUrls = true); - When a URL must change, leave a permanent redirect behind — this platform keeps a legacy-redirect middleware for exactly that, so old links 301 to their successors instead of 404ing.
Comments (0)
No comments yet — be the first to share your thoughts.