[Route]

Declare controller URL templates with [Route]: class prefixes, absolute overrides, tokens, route names and Order, and avoid ambiguous or verb-less routes.

Signature
[Route(string template, Name = "...", Order = 0)]
  • Added in .NET ASP.NET Core 1.0

Parameters

ParameterTypeDescription
$templatestringThe route template, for example "api/customers/{customerId:int}/orders". A leading "/" or "~/" on an action template ignores the controller prefix. Tokens [controller], [action] and [area] are replaced; write [[ and ]] for literal brackets.
$Nameoptionalstring?= nullRoute name for URL generation (CreatedAtRoute, LinkGenerator.GetPathByName). Names must be unique across the whole application.
$Orderoptionalint= 0Tie-breaker between endpoints that both match: lower values are tried first. Overrides the precedence computed from template specificity.

Return value

n/aSupplies a route template (IRouteTemplateProvider). On a controller it is a prefix combined with every action template; on an action it adds a route that matches any HTTP method.

Errors

Startup fails with InvalidOperationException when two different templates use the same Name, or when a token such as [action] cannot be replaced. Two endpoints matching the same request with equal precedence throw AmbiguousMatchException (HTTP 500) at request time.

Changelog

  • PHP ASP.NET Core 3.0Attribute routes became endpoints in endpoint routing, so they share matching, constraints and metadata with every other endpoint type.

[Route] gives a controller or action a URL template. At startup MVC combines the templates it finds into one endpoint per (action, template) pair and hands them to endpoint routing, the same matcher that serves minimal APIs. Matching is case-insensitive and constraints such as {id:int} work exactly as in MapGet.

How templates combine

ControllerActionEndpoint
[Route("api/orders")][HttpGet]GET api/orders
[Route("api/orders")][HttpGet("{id:int}")]GET api/orders/{id:int}
[Route("api/orders")][HttpGet("/api/reports/orders")]GET api/reports/orders (prefix ignored)
[Route("api/orders")][Route("archive")]api/orders/archive for every HTTP method
two [Route] attributes[HttpGet("{id}")]one endpoint per controller route

Several [Route] or [HttpGet] attributes on one action create several endpoints, which is how an old URL can stay alive while clients migrate.

Tokens, names and order

[controller] becomes the class name without the Controller suffix, keeping its casing, so generated links read /api/Orders/42. Most teams write literal routes instead: renaming a class must never change a public URL. If you do use tokens, a RouteTokenTransformerConvention can turn them into kebab-case. A route Name exists for link generation and must be unique application-wide. When two endpoints match one request, routing prefers the more specific template (literals over parameters, constrained over unconstrained); Order overrides that, lower first. If neither decides, the request fails with AmbiguousMatchException and a 500.

Production failure modes

  • An action with [Route] but no [Http...] attribute answers GET, POST, DELETE and every other method. A crawler issuing GET against a state-changing action is the classic result. Use the verb attributes on actions and keep [Route] for the class.
  • A new {slug} route next to an existing {id} route makes both match /api/products/42. Constrain them ({id:int}) rather than relying on Order.
  • Minimal APIs ignore [Route] entirely; use MapGroup("/api/orders") for a prefix there.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Nest orders under customers with a class prefix so every action gets /api/customers/{customerId:int}/orders without repeating it
  • Keep /orders/recent answering for an old mobile app while new clients move to /api/orders/recent
  • Generate the Location header for a created order by route name instead of concatenating strings

Frequently asked questions

Does [Route("api/[controller]")] produce lowercase URLs?

No. The token keeps the class name's casing; matching is case-insensitive so requests still work, but generated links contain /api/Orders. Set RouteOptions.LowercaseUrls or write literal templates.

Can I use [Route] on a minimal API handler?

No. Minimal APIs take their template from MapGet and friends; use MapGroup for a shared prefix.

What is the difference between Order and precedence?

Precedence is computed from the template: literals beat parameters, constrained beats unconstrained. Order is an explicit override, lower first, for cases precedence cannot decide. Needing Order usually means the templates should be constrained instead.

More examples

Kebab-case URLs from tokens

The transformer runs when tokens are replaced at startup, so <code>/api/payment-methods</code> is the only URL that exists, and links generated by <code>LinkGenerator</code> use it too. It is still tied to class names; literal templates remain the safer choice for a public API.

Example · csharp
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApplicationModels;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers(options =>
    options.Conventions.Add(new RouteTokenTransformerConvention(new KebabCaseTransformer())));

var app = builder.Build();
app.MapControllers();
app.Run();

// [controller] on PaymentMethodsController now becomes "payment-methods".
public partial class KebabCaseTransformer : IOutboundParameterTransformer
{
    public string? TransformOutbound(object? value) =>
        value is null ? null : Boundary().Replace(value.ToString()!, "$1-$2").ToLowerInvariant();

    [GeneratedRegex("([a-z0-9])([A-Z])")]
    private static partial Regex Boundary();
}

[ApiController]
[Route("api/[controller]")]
public class PaymentMethodsController : ControllerBase
{
    [HttpGet]
    public IActionResult List() => Ok(new[] { "card", "sepa_debit" });
}

Two templates that collide, and the fix

Routing ranks templates by specificity: literal segments beat parameters, and constrained parameters beat unconstrained ones. Precedence does not rank one constraint type above another, so <code>{id:int}</code> and <code>{slug:regex(...)}</code> tie, and the constraints themselves must be mutually exclusive: the slug regex requires at least one letter, which no integer has. The regex is written with doubled brackets <code>[[</code> and <code>]]</code> because single brackets are token syntax in attribute routes.

Example · csharp
using Microsoft.AspNetCore.Mvc;

namespace Shop.Api;

[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
    // Before: "{id}" and "{slug}" both matched /api/products/42 -> AmbiguousMatchException (500).
    // A slug regex of ^[a-z0-9-]+$ would still match "42" and collide again.
    // After: a slug must contain a letter, so no URL satisfies both constraints.
    [HttpGet("{id:int}")]
    public IActionResult GetById(int id) => Ok(new { id });

    [HttpGet("{slug:regex(^[[a-z0-9-]]*[[a-z]][[a-z0-9-]]*$)}")]
    public IActionResult GetBySlug(string slug) => Ok(new { slug });

    // A literal segment always beats a parameter, so this needs no Order.
    [HttpGet("discontinued")]
    public IActionResult Discontinued() => Ok(Array.Empty<object>());
}

A test that fails on verb-less actions

A reflection test turns the convention into a build failure: any public action without an <code>IActionHttpMethodProvider</code> attribute (<code>[HttpGet]</code>, <code>[HttpPost]</code>, <code>[AcceptVerbs]</code>) is listed. Point <code>typeof(...)</code> at a type in the API assembly in a real project.

Example · csharp
using System.Reflection;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using Xunit;

namespace Shop.Api.Tests;

public class RouteConventionTests
{
    [Fact]
    public void Every_action_declares_an_http_method()
    {
        var offenders = typeof(RouteConventionTests).Assembly.GetTypes()
            .Where(t => typeof(ControllerBase).IsAssignableFrom(t) && !t.IsAbstract)
            .SelectMany(t => t.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
            .Where(m => m.GetCustomAttribute<NonActionAttribute>() is null)
            .Where(m => !m.GetCustomAttributes().OfType<IActionHttpMethodProvider>().Any())
            .Select(m => $"{m.DeclaringType!.Name}.{m.Name}")
            .ToList();

        Assert.Empty(offenders);
    }
}

Discussion

  • Be the first to comment on this lesson.