Attribute routing places route templates directly on controllers and actions using attributes — [Route], [HttpGet], [HttpPost], etc. This gives precise, explicit control over every URL. In conventional routing (Chapter 25), the URL structure is inferred from controller and action names; in attribute routing, you declare exactly what URL maps to what. Attribute routing is preferred for Web APIs (Chapter 34) and for MVC applications where URL structure matters for SEO, versioning, or does not fit the {controller}/{action}/{id} pattern.
Attribute Routing Fundamentals
// ── [Route] on controller sets the base path ──────────────────────────────
[Route("blog")]
public class BlogController : Controller
{
// GET /blog
[HttpGet("")]
[HttpGet("index")]
public async Task<IActionResult> Index() => View();
// GET /blog/2025/my-post-title
[HttpGet("{year:int}/{slug}")]
public async Task<IActionResult> Post(int year, string slug)
{
var post = await _service.GetByYearAndSlugAsync(year, slug);
return post is null ? NotFound() : View(post);
}
// GET /blog/category/technology
[HttpGet("category/{categorySlug}")]
public async Task<IActionResult> ByCategory(string categorySlug)
=> View(await _service.GetByCategoryAsync(categorySlug));
// POST /blog/posts/create
[HttpPost("posts/create")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreatePostViewModel model) { /* ... */ return View(); }
}
// ── Token replacement — [action] and [controller] in route templates ───────
[Route("[controller]")] // uses the controller name: /posts
public class PostsController : Controller
{
[HttpGet("[action]")] // /posts/index
public IActionResult Index() => View();
[HttpGet("[action]/{id:int}")] // /posts/details/42
public async Task<IActionResult> Details(int id) => View();
// Named route — used for URL generation
[HttpGet("{id:int}", Name = "GetPostById")]
public async Task<IActionResult> GetById(int id) => View();
}
MapControllerRoute() handles controllers without [Route] attributes; controllers with [Route] attributes use their declared templates exclusively. However, a single controller should not mix both: either use attribute routing consistently on all actions, or rely on conventional routing — mixing causes confusing URL behaviour and difficult-to-debug route conflicts.[HttpGet("{id:int}", Name = "GetPostById")]) for URLs you need to generate programmatically — for canonical links, Location headers after creating resources, or email links. Named routes allow Url.RouteUrl("GetPostById", new { id = post.Id }) to generate the URL, which remains correct even if the route template changes. Never hardcode URL strings; always generate from route names or action/controller combinations.[Route("blog")] and an action-level [HttpGet("posts/create")] produce /blog/posts/create. If you want the action template to be absolute (not prefixed by the controller route), start it with /: [HttpGet("/standalone")] produces /standalone regardless of the controller-level route. This is useful for actions that do not logically belong under the controller’s URL prefix.Multiple Routes on One Action
// ── Multiple route attributes — same action, multiple URL paths ────────────
[Route("posts")]
public class PostsController : Controller
{
// This action responds to BOTH routes
[HttpGet("")] // GET /posts
[HttpGet("page/{page:int}")] // GET /posts/page/2
public async Task<IActionResult> Index(int page = 1)
=> View(await _service.GetPageAsync(page, 10));
// Support both ID and slug access for the same post
[HttpGet("{id:int}")] // GET /posts/42
[HttpGet("{slug:regex(^[a-z0-9-]+$)}")] // GET /posts/my-post-title
public async Task<IActionResult> Details(int id = 0, string slug = "")
{
var post = id > 0
? await _service.GetByIdAsync(id)
: await _service.GetBySlugAsync(slug);
return post is null ? NotFound() : View(post);
}
}
Common Mistakes
Mistake 1 — Mixing conventional and attribute routing on the same controller
❌ Wrong — some actions have [Route], others rely on convention; unpredictable URL behaviour.
✅ Correct — choose one routing strategy per controller and apply it consistently to all actions.
Mistake 2 — Forgetting the controller-level [Route] prefix (action routes become root-level)
❌ Wrong — action has [HttpGet(“details/{id}”)] but no controller-level route; resolves to /details/42, not /posts/details/42.
✅ Correct — always set [Route(“controllerName”)] on the controller or use [Route(“[controller]”)].