All articles

ASP.NET Core MVC Routing Explained with Examples

Conventional and attribute routing in ASP.NET Core — the default pattern, API route templates, parameters and constraints, catch-alls, and generating URLs instead of hard-coding them.

0 · log in to like, save & follow Share on LinkedIn Share on X

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.

cover

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 an id parameter 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 minus Controller (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

  1. Routing middleware (UseRouting, implicit in minimal hosting) matches the URL against endpoints and picks the best candidate.
  2. Route values (controller, action, id, …) go into the request.
  3. Middleware between routing and the endpoint (authorization, CORS) runs with full knowledge of the target.
  4. 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 EndpointDataSource and dump Endpoints — the fastest way to see what a template actually registered.
  • Check constraint failures — /demo/route-bind/xyz 404s by design; the log at Debug level for Microsoft.AspNetCore.Routing shows candidates considered and rejected.
  • Ambiguous matches throw AmbiguousMatchException at 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)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.